Compare commits

...

10 Commits

Author SHA1 Message Date
Matthieu ac86500266 feat(catalog) : ERP-200 — ProductProvider + ProductProcessor (unicité code, RG-6.03/05/06, normalisation)
Provider de lecture (liste paginée Hydra filtrée + item) :
- exclut les produits soft-deleted (RG-6.09), tri name ASC ;
- filtres ?search (code+name), ?categoryId/?categoryCode, ?state (JSONB @>), ?siteId[] (EXISTS) ;
- Get item : 404 sur soft-deleted (non exposé au M6, § 2.7) ;
- pagination obligatoire via Paginator ORM (règle n°13), échappatoire ?pagination=false.

Processor d'écriture (POST/PATCH) :
- normalisation serveur code trim+UPPER, name trim (RG-6.07, ProductFieldNormalizer) ;
- RG-6.03 : manufactured/containsMolasses forcés false si states sans SALE ;
- RG-6.01 : unicité globale du code parmi les actifs -> 409 (pré-check + filet anti-race index partiel), propertyPath code côté front.

Entité Product : Assert\Callback RG-6.05 (catégorie de type PRODUIT) et RG-6.06
(types de stockage disponibles sur au moins un site choisi), atPath pour mapping
inline 422 ; constantes d'états.

Repository : createListQueryBuilder (filtres + eager-load category/sites/storageTypes)
+ existsActiveByCode déjà en place.

make test vert (873 tests), php-cs-fixer OK.
2026-06-25 11:16:03 +02:00
Matthieu bc14e3893b feat(catalog) : ERP-199 — entités Product + StorageType + repositories + contrat de sérialisation
Entité Product (#[Auditable], TimestampableBlamable, soft-delete préparé non
exposé) et référentiel StorageType (lecture seule, provisoire) dans le module
Catalog, avec le contrat de sérialisation posé une fois (read-groups par
propriété affichée — RETEX M1→M5, 3 maillons spec § 4.0).

- Product : code (unique global RG-6.01), name, states (json multi-select
  PURCHASE/SALE/OTHER ≥1, RG-6.02), manufactured/containsMolasses (RG-6.03),
  category ManyToOne (PRODUIT, RG-6.05), sites + storageTypes ManyToMany (≥1).
  Messages FR sur toutes les contraintes, Length calée colonnes. Opérations
  Get/GetCollection (.view) + Post/Patch (.manage), pas de Delete. Provider/
  Processor référencés (implémentés en ERP-200).
- StorageType : code/label + sites ManyToMany (filtrage par site, ERP-201).
  Référentiel statique → whitelist EntitiesAreTimestampableBlamableTest.
- Repositories Product/StorageType (interfaces Domain + impl Doctrine).
- Validation états via Assert\Choice(multiple) plutôt qu'Assert\All (seul
  Choice est géré par EntityConstraintsHaveFrenchMessageTest).
- Garde-fous schema:update : 5 tables M6 ajoutées à ColumnCommentsCatalog,
  index partiel uq_product_code_active rejoué dans makefile test-db-setup.
- i18n audit.entity.catalog_product.
2026-06-25 10:44:34 +02:00
Matthieu 5409c79d1d feat(catalog) : ERP-198 — migration schéma M6 (storage_type, product, jonctions, type PRODUIT) 2026-06-25 10:20:58 +02:00
Matthieu e9f8b0bc45 feat(catalog) : ERP-197 — permissions catalog.products.* + item sidebar + 3 miroirs RBAC
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m53s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m31s
- CatalogModule::permissions() : ajout catalog.products.view / .manage (admin-only, C7)
- config/sidebar.php : item « Catalogue produit » (/admin/products) sous « Repertoire transporteurs » (section Administration)
- personas.ts + SeedE2ECommand.php : persona admin gagne view/manage + lien products (3 miroirs RBAC alignes)
- i18n : cle sidebar.catalog.products
2026-06-25 09:50:29 +02:00
gitea-actions 817975e0b7 chore: bump version to v0.1.151
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 38s
2026-06-25 07:26:34 +00:00
tristan efded9fd40 feat(commercial) : catégories de type Adresse pour les blocs adresse (client + fournisseur) (#147)
Auto Tag Develop / tag (push) Successful in 12s
## Objectif

Introduit un `CategoryType` dédié **ADRESSE** (module Catalog) consommé par le champ « Catégorie » des blocs adresse, en remplacement de la réutilisation détournée des types CLIENT / FOURNISSEUR.

## Changements

**Backend**
- Migration de seed du type ADRESSE + 6 catégories : Siège, Contact issues, Facturation, Livraison, Approvisionnement, Méthaniseur (idempotente, réversible) ; fixtures alignées.
- `ClientAddress` : validation blacklist (DISTRIBUTEUR/COURTIER) remplacée par une whitelist « catégories de type ADRESSE uniquement ».
- `SupplierAddress` : type requis FOURNISSEUR → ADRESSE (le bloc principal fournisseur reste en FOURNISSEUR).

**Frontend**
- Ref dédiée `addressCategories` (`?typeCode=ADRESSE`) dans les composables référentiels client et fournisseur.
- Pages new/edit client et fournisseur câblées sur les blocs adresse.

**Tests**
- `CategoryAdresseSeedTest` (miroir du test PRESTATAIRE).
- Adaptation des tests d'adresse client/fournisseur (sémantique whitelist ADRESSE) + helper `createAddressCategory()`.

## Vérifications
- Back : suites Catalog + Architecture + adresse/fournisseur vertes (le flake JWT connu du hook est sans rapport, tests verts en isolation).
- Front : Vitest vert (composables référentiels + ciblés).
- php-cs-fixer : 0 correction ; eslint : OK.

Reviewed-on: #147
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-25 07:26:21 +00:00
gitea-actions 2e50a760c6 chore: bump version to v0.1.150
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 47s
2026-06-24 17:14:00 +00:00
tristan 49e5e5548e feat(front) : refonte à plat des blocs Information (commercial) et Prix (transporteur) (#146)
Auto Tag Develop / tag (push) Successful in 11s
Complète la refonte **ERP-196** (blocs de formulaire à plat : sans box-shadow, titre noir, filet noir 1px) qui avait oublié deux blocs.

## Blocs concernés
- **Bloc « Information »** (Client + Fournisseur, écrans consultation / édition / création — 6 fichiers) : suppression du fond blanc, du box-shadow et du padding latéral → grille à plat pleine largeur. Pas de titre ajouté (le bloc est seul dans son onglet « Information », comme le bloc du haut du ticket de pesée).
- **Bloc « Prix » du transporteur** (`CarrierPriceBlock`) : aligné sur les blocs contact / adresse — à plat, en-tête « Prix N » en noir + poubelle (`button-class="p-0"`), filet noir 1px entre blocs (sauf le dernier via la prop `last`). Câblage `title`/`last` dans les écrans Ajouter / Modifier + clé i18n `carriers.form.price.title`.

## Hors périmètre
La table de **consultation** des prix (lecture seule, avec export) n'est pas un bloc de formulaire et garde sa présentation actuelle.

## Vérifications
- Vitest : suite complète verte (667/667).
- ESLint : clean sur l'ensemble du projet.
- Aucune modif back.

> Pas de numéro de ticket fourni — branche nommée descriptivement, à renommer/rattacher si besoin.

Reviewed-on: #146
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 17:13:48 +00:00
gitea-actions fd430bc123 chore: bump version to v0.1.149
Auto Tag Develop / tag (push) Successful in 10s
Build & Push Docker Image / build (push) Successful in 3m9s
2026-06-24 16:05:04 +00:00
tristan a6b48b1dd1 feat : ERP-196 — refonte des blocs de formulaire (contact, adresse, compta) (#145)
Auto Tag Develop / tag (push) Successful in 11s
## ERP-196 — Refonte des blocs de formulaire

Refonte visuelle des blocs répétables des formulaires (clients, fournisseurs, prestataires, transporteurs), alignée sur les blocs « ticket de pesée » : à plat (sans box-shadow), titre de bloc en noir, séparation par filet noir 1px.

###  Blocs Contact
- Box-shadow / fond blanc / padding latéral retirés
- En-tête `flex justify-between` : titre noir (« Contact 1 »…) à gauche, poubelle `button-class="p-0"` à droite
- 4 colonnes, filet `border-b border-black` entre blocs (pas sous le dernier, prop `last`)
- i18n `contact.title` ajouté pour transporteurs / prestataires
- 9 pages câblées (new / edit / consultation des 4 répertoires)

###  Blocs Adresse
- Même traitement (à plat, titre noir, filet sauf dernier)
- i18n `address.title` pour transporteurs / prestataires
- Transporteur : adresse unique → titre « Adresse » (sans numéro)
- 12 pages câblées

###  Bloc Comptabilité
- Bloc **infos** : titre « Informations » + filet bas (uniquement si des RIB suivent)
- Blocs **RIB** : titre « RIB 1 / RIB 2… » + poubelle `p-0`, filet sauf le dernier
- i18n `accounting.infoTitle` (3 modules) + `accounting.ribTitle` (fournisseurs / prestataires)
- 9 pages câblées (clients / fournisseurs / prestataires)

### Vérifications
- Vitest : 44/44 (specs contact + adresse)
- Eslint : clean sur l'ensemble des composants et pages modifiés

### Commits
- `feat : refonte des blocs contact (ERP-196)`
- `feat : refonte des blocs adresse (ERP-196)`
- `feat : refonte du bloc comptabilité (ERP-196)`

Reviewed-on: #145
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-24 16:04:52 +00:00
60 changed files with 3352 additions and 1323 deletions
+10
View File
@@ -134,6 +134,16 @@ return [
'module' => 'transport', 'module' => 'transport',
'permission' => 'transport.carriers.view', 'permission' => 'transport.carriers.view',
], ],
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
// `catalog.products.view` et son module owner `catalog`.
[
'label' => 'sidebar.catalog.products',
'to' => '/admin/products',
'icon' => 'mdi:package-variant-closed',
'module' => 'catalog',
'permission' => 'catalog.products.view',
],
[ [
'label' => 'sidebar.core.roles', 'label' => 'sidebar.core.roles',
'to' => '/admin/roles', 'to' => '/admin/roles',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.148' app.version: '0.1.151'
+13 -1
View File
@@ -52,7 +52,8 @@
"admin": "Sites" "admin": "Sites"
}, },
"catalog": { "catalog": {
"categories": "Gestion des catégories" "categories": "Gestion des catégories",
"products": "Catalogue produit"
} }
}, },
"dashboard": { "dashboard": {
@@ -183,6 +184,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -190,6 +192,7 @@
"paymentDelay": "Délai de règlement", "paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement", "paymentType": "Type de règlement",
"bank": "Banque", "bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé", "ribLabel": "Libellé",
"ribBic": "BIC", "ribBic": "BIC",
"ribIban": "IBAN", "ribIban": "IBAN",
@@ -350,6 +353,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -441,6 +445,7 @@
"categoryRequired": "Sélectionnez au moins une catégorie." "categoryRequired": "Sélectionnez au moins une catégorie."
}, },
"contact": { "contact": {
"title": "Contact {n}",
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
"jobTitle": "Fonction", "jobTitle": "Fonction",
@@ -452,6 +457,7 @@
"add": "Nouveau contact" "add": "Nouveau contact"
}, },
"address": { "address": {
"title": "Adresse {n}",
"sites": "Sites", "sites": "Sites",
"contacts": "Contact(s) rattaché(s)", "contacts": "Contact(s) rattaché(s)",
"country": "Pays", "country": "Pays",
@@ -465,6 +471,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"accounting": { "accounting": {
"infoTitle": "Informations",
"siren": "SIREN", "siren": "SIREN",
"accountNumber": "Numéro de compte", "accountNumber": "Numéro de compte",
"tvaMode": "Mode de TVA", "tvaMode": "Mode de TVA",
@@ -472,6 +479,7 @@
"paymentDelay": "Délai de règlement", "paymentDelay": "Délai de règlement",
"paymentType": "Type de règlement", "paymentType": "Type de règlement",
"bank": "Banque", "bank": "Banque",
"ribTitle": "RIB {n}",
"ribLabel": "Libellé", "ribLabel": "Libellé",
"ribBic": "BIC", "ribBic": "BIC",
"ribIban": "IBAN", "ribIban": "IBAN",
@@ -628,6 +636,7 @@
"uploadFailed": "Le téléversement de la décharge a échoué." "uploadFailed": "Le téléversement de la décharge a échoué."
}, },
"address": { "address": {
"title": "Adresse",
"country": "Pays", "country": "Pays",
"postalCode": "Code postal", "postalCode": "Code postal",
"city": "Ville", "city": "Ville",
@@ -637,6 +646,7 @@
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
}, },
"contact": { "contact": {
"title": "Contact {n}",
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
"jobTitle": "Fonction", "jobTitle": "Fonction",
@@ -654,6 +664,7 @@
"confirm": "Supprimer" "confirm": "Supprimer"
}, },
"price": { "price": {
"title": "Prix {n}",
"direction": "Sens", "direction": "Sens",
"directionClient": "Client", "directionClient": "Client",
"directionSupplier": "Fournisseur", "directionSupplier": "Fournisseur",
@@ -806,6 +817,7 @@
"core_permission": "Permission", "core_permission": "Permission",
"sites_site": "Site", "sites_site": "Site",
"catalog_category": "Catégorie", "catalog_category": "Catégorie",
"catalog_product": "Produit",
"commercial_client": "Client", "commercial_client": "Client",
"commercial_clientaddress": "Adresse client", "commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client", "commercial_clientcontact": "Contact client",
@@ -1,203 +1,211 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> (pas de bordure sous le dernier bloc). -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur) <!-- Grille 4 colonnes des champs de l'adresse. -->
remplacant les 3 cases. Les options encodent les combinaisons valides <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
(exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les <!-- Usage de l'adresse : Select unique (plus simple pour l'utilisateur)
drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). --> remplacant les 3 cases. Les options encodent les combinaisons valides
<!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire + (exclusivite Prospect, RG-1.06/07/08) ; le back recoit toujours les
exclusivite prospect) -> affichee sous le select Type d'adresse. --> drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). -->
<MalioSelect <!-- Erreur portee sur `isProspect` cote back (Callback type obligatoire +
:model-value="addressType" exclusivite prospect) -> affichee sous le select Type d'adresse. -->
:options="addressTypeOptions" <MalioSelect
:label="t('commercial.clients.form.address.addressType')" :model-value="addressType"
:readonly="readonly" :options="addressTypeOptions"
:disabled="disabled" :label="t('commercial.clients.form.address.addressType')"
:required="!readonly && !disabled"
:error="errors?.isProspect"
@update:model-value="onAddressTypeChange"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
<MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
telephone secondaire) qui coule dans la grille. Sinon un filler comble
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="!readonly && !disabled"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/>
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
Inutile en consultation masquee (la grille se recompose sans les
champs vides, ERP-193). -->
<div v-else-if="!hideEmpty" aria-hidden="true" />
<MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
/>
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
indisponible, bascule en saisie libre — recuperable : re-saisir le
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
sa valeur liee, il n'afficherait rien en readonly). allow-create :
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:required="!readonly && !disabled" :required="!readonly && !disabled"
:error="errors?.street" :error="errors?.isProspect"
:allow-create="true" @update:model-value="onAddressTypeChange"
:no-results-text="t('commercial.clients.form.address.streetNotFound')" />
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" <!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-1.10). -->
@select="onAddressSelect" <MalioSelectCheckbox
:model-value="model.siteIris"
:options="siteOptions"
:label="t('commercial.clients.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
telephone secondaire) qui coule dans la grille. Sinon un filler comble
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
<MalioInputEmail
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="!readonly && !disabled"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly"
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
@update:model-value="(v: string) => update('billingEmail', v)"
@add="revealSecondaryBillingEmail"
/>
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
Inutile en consultation masquee (la grille se recompose sans les
champs vides, ERP-193). -->
<div v-else-if="!hideEmpty" aria-hidden="true" />
<MalioInputEmail
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
:model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
/>
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Si la BAN est
indisponible, bascule en saisie libre — recuperable : re-saisir le
code postal relance la recherche et repasse en select au succes. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/> />
<MalioInputText <MalioInputText
v-else v-else
:model-value="model.street" :model-value="model.city"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:mask="ADDRESS_MASK" :mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:error="errors?.streetComplement" :required="!readonly && !disabled"
@update:model-value="(v: string) => update('streetComplement', v)" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/> />
</div>
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple
seulement en lecture seule (MalioInputAutocomplete ne reaffiche pas
sa valeur liee, il n'afficherait rien en readonly). allow-create :
si la BAN ne propose rien (ou erreur), le texte saisi est CONSERVE au
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div>
</div> </div>
</template> </template>
@@ -230,6 +238,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,84 +1,93 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<MalioButtonIcon <div class="flex items-center justify-between">
v-if="removable && !readonly && !disabled" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
icon="mdi:delete-outline" <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
variant="ghost" non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
button-class="absolute top-3 right-3" ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('commercial.clients.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('commercial.clients.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText :model-value="model.firstName"
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la :label="t('commercial.clients.form.contact.firstName')"
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> :mask="PERSON_NAME_MASK"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :readonly="readonly"
<MalioInputText :disabled="disabled"
:model-value="model.jobTitle" :error="errors?.firstName"
:label="t('commercial.clients.form.contact.jobTitle')" @update:model-value="(v: string) => update('firstName', v)"
:mask="FREE_TEXT_MASK" />
:readonly="readonly" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:disabled="disabled" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:error="errors?.jobTitle" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
@update:model-value="(v: string) => update('jobTitle', v)" <div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.clients.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div> </div>
</template> </template>
@@ -98,6 +107,8 @@ const props = defineProps<{
title: string title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */ /** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,189 +1,198 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : modal de confirmation cote parent. --> (pas de bordure sous le dernier bloc). -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- Suppression : modal de confirmation cote parent. -->
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('commercial.suppliers.form.address.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant <!-- Grille 4 colonnes des champs de l'adresse. -->
l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
`addressType`) s'affiche via la prop native :error de MalioSelect. --> <!-- Type d'adresse : Prospect / Depart / Rendu (RG-2.09). Select en attendant
<MalioSelect l'arbitrage metier (radio vs select) ; l'erreur 422 (propertyPath
:model-value="model.addressType" `addressType`) s'affiche via la prop native :error de MalioSelect. -->
:options="addressTypeOptions" <MalioSelect
:label="t('commercial.suppliers.form.address.addressType')" :model-value="model.addressType"
:readonly="readonly" :options="addressTypeOptions"
:disabled="disabled" :label="t('commercial.suppliers.form.address.addressType')"
empty-option-label="" :readonly="readonly"
:required="!readonly && !disabled" :disabled="disabled"
:error="errors?.addressType" empty-option-label=""
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))" :required="!readonly && !disabled"
/> :error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/>
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). --> <!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-2.06). -->
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="model.siteIris" :model-value="model.siteIris"
:options="siteOptions" :options="siteOptions"
:label="t('commercial.suppliers.form.address.sites')" :label="t('commercial.suppliers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:required="!readonly && !disabled" :required="!readonly && !disabled"
:error="errors?.street" :error="errors?.sites"
:allow-create="true" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')" />
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch" <!-- Contacts rattaches (M2M, facultatif). -->
@select="onAddressSelect" <MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
porte ici l'email de facturation, absent cote fournisseur). Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.suppliers.form.address.categories')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.suppliers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.suppliers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/> />
<MalioInputText <MalioInputText
v-else v-else
:model-value="model.street" :model-value="model.city"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK" :mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:required="!readonly && !disabled" :required="!readonly && !disabled"
:error="errors?.street" :error="errors?.city"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('city', v)"
/> />
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
<MalioInputText texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
:model-value="model.streetComplement" <div class="col-span-2">
:label="t('commercial.suppliers.form.address.streetComplement')" <MalioInputAutocomplete
:mask="ADDRESS_MASK" v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.suppliers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.suppliers.form.address.street')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:error="errors?.streetComplement" :error="errors?.bennes"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/> />
</div> </div>
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
<MalioInputNumber
v-if="!hideEmpty || isFilled(model.bennes)"
:model-value="model.bennes"
:label="t('commercial.suppliers.form.address.bennes')"
:min="0"
:readonly="readonly"
:disabled="disabled"
:error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)"
/>
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
Consultation : masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="!hideEmpty || isFilled(model.triageProvider)"
id="address-triage-provider"
:label="t('commercial.suppliers.form.address.triageProvider')"
:model-value="model.triageProvider"
group-class="self-center"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)"
/>
</div> </div>
</template> </template>
@@ -210,6 +219,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,83 +1,92 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc, RG-2.13) ou en lecture seule. --> <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
v-if="removable && !readonly && !disabled" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
variant="ghost" <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
button-class="absolute top-3 right-3" non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('commercial.suppliers.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('commercial.suppliers.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('commercial.suppliers.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('commercial.suppliers.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText :model-value="model.firstName"
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la :label="t('commercial.suppliers.form.contact.firstName')"
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> :mask="PERSON_NAME_MASK"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :readonly="readonly"
<MalioInputText :disabled="disabled"
:model-value="model.jobTitle" :error="errors?.firstName"
:label="t('commercial.suppliers.form.contact.jobTitle')" @update:model-value="(v: string) => update('firstName', v)"
:mask="FREE_TEXT_MASK" />
:readonly="readonly" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:disabled="disabled" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:error="errors?.jobTitle" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
@update:model-value="(v: string) => update('jobTitle', v)" <div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div> </div>
</template> </template>
@@ -96,6 +105,8 @@ const props = defineProps<{
title: string title: string
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */ /** Affiche l'icone de suppression (1er bloc non supprimable, RG-2.13). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -77,4 +77,23 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal). // Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }]) expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
}) })
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
// Le mock distingue les deux appels /categories par leur filtre typeCode.
mockGet.mockImplementation((url: string, query?: Record<string, unknown>) => {
if (url === '/categories' && query?.typeCode === 'CLIENT') {
return Promise.resolve({ member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }] })
}
if (url === '/categories' && query?.typeCode === 'ADRESSE') {
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'SIEGE', name: 'Siège' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useClientReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }])
expect(refs.addressCategories.value).toEqual([{ value: '/api/categories/9', label: 'Siège', code: 'SIEGE' }])
})
}) })
@@ -23,6 +23,16 @@ describe('useSupplierReferentials', () => {
) )
}) })
it('charge les categories d\'adresse filtrees sur le type ADRESSE', async () => {
await useSupplierReferentials().loadCommon()
expect(mockGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'ADRESSE' }),
expect.objectContaining({ toast: false }),
)
})
it('mappe les categories en options { value: IRI, label: name, code }', async () => { it('mappe les categories en options { value: IRI, label: name, code }', async () => {
mockGet.mockImplementation((url: string) => { mockGet.mockImplementation((url: string) => {
if (url === '/categories') { if (url === '/categories') {
@@ -68,6 +68,9 @@ export function useClientReferentials() {
const api = useApi() const api = useApi()
const categories = ref<CategoryOption[]>([]) const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// CLIENT du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([]) const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
@@ -109,6 +112,9 @@ export function useClientReferentials() {
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API. // de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' }) fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja // postal du site), ex: 86100 -> « 86 ». Le code postal est deja
@@ -151,6 +157,7 @@ export function useClientReferentials() {
return { return {
categories, categories,
addressCategories,
sites, sites,
tvaModes, tvaModes,
paymentDelays, paymentDelays,
@@ -62,6 +62,9 @@ export function useSupplierReferentials() {
const api = useApi() const api = useApi()
const categories = ref<CategoryOption[]>([]) const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// FOURNISSEUR du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([]) const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
@@ -97,6 +100,9 @@ export function useSupplierReferentials() {
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API. // categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' }) fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». // postal du site), ex: 86100 -> « 86 ».
@@ -121,6 +127,7 @@ export function useSupplierReferentials() {
return { return {
categories, categories,
addressCategories,
sites, sites,
tvaModes, tvaModes,
paymentDelays, paymentDelays,
@@ -93,7 +93,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
@@ -178,6 +178,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly" :disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -210,6 +211,7 @@
:key="address.id ?? `new-${index}`" :key="address.id ?? `new-${index}`"
:model-value="address" :model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions" :category-options="addressCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -244,8 +246,10 @@
editable uniquement si accounting.manage). --> editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -314,21 +318,27 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`" :key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -469,9 +479,6 @@ import { readHistoryTab } from '~/shared/utils/historyTab'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
@@ -563,15 +570,17 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))] return [...primary, ...extra.filter(o => !seen.has(o.value))]
} }
const embedCategoryOptions = computed<CategoryOption[]>(() => { // Categories du formulaire principal (type CLIENT) : referentiel UNION categories
const fromClient = categoryOptionsOf(client.value?.categories) // embarquees du client (fallback si le referentiel n'est pas chargeable).
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories)) const embedClientCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(client.value?.categories))
return mergeOptions(fromClient, fromAddresses) const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedClientCategoryOptions.value))
}) // Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value)) // embarquees des adresses (fallback meme fonction qu'au-dessus).
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29). const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
)
const addressCategoryOptions = computed(() => const addressCategoryOptions = computed(() =>
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)), mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
) )
const embedSiteOptions = computed<RefOption[]>(() => const embedSiteOptions = computed<RefOption[]>(() =>
@@ -96,7 +96,7 @@
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). --> coussin de chaque cote). -->
@@ -156,6 +156,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -170,6 +171,7 @@
:key="view.draft.id ?? index" :key="view.draft.id ?? index"
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -183,8 +185,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(accounting.siren)" v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
@@ -239,13 +243,16 @@
</div> </div>
</div> </div>
<!-- Blocs RIB (0..n), lecture seule. --> <!-- Blocs RIB (0..n), lecture seule.
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="rib.id ?? index" :key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(rib.label)" v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
@@ -87,7 +87,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
le champ de 40px est centre dans un conteneur h-12 (~4px de le champ de 40px est centre dans un conteneur h-12 (~4px de
coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px coussin en HAUT et en BAS). Sans pb-1, le textarea descend ~4px
@@ -177,6 +177,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })" :title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')" :disabled="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -209,6 +210,7 @@
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('commercial.clients.form.address.title', { n: index + 1 })" :title="t('commercial.clients.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="addressCategoryOptions" :category-options="addressCategoryOptions"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -242,8 +244,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
@@ -312,22 +316,28 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-1.13).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
<MalioButtonIcon <div class="flex items-center justify-between">
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
icon="mdi:delete-outline" <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
@@ -446,9 +456,6 @@ const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7). // Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
@@ -806,10 +813,8 @@ async function submitContacts(): Promise<void> {
const addresses = ref<AddressFormDraft[]>([emptyAddress()]) const addresses = ref<AddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false) const addressDegradedNotified = ref(false)
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29). // Categories autorisees sur une adresse : taxonomie dediee type ADRESSE.
const addressCategoryOptions = computed(() => const addressCategoryOptions = computed(() => referentials.addressCategories.value)
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI). // Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
const contactOptions = computed<RefOption[]>(() => const contactOptions = computed<RefOption[]>(() =>
@@ -56,7 +56,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. --> <!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs. -->
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
@@ -147,6 +147,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly" :disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -179,7 +180,8 @@
:key="address.id ?? `new-${index}`" :key="address.id ?? `new-${index}`"
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="mainCategoryOptions" :last="index === addresses.length - 1"
:category-options="addressCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
@@ -213,8 +215,10 @@
editable uniquement si accounting.manage). --> editable uniquement si accounting.manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
@@ -283,21 +287,27 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="rib.id ?? `new-${index}`" :key="rib.id ?? `new-${index}`"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -526,15 +536,18 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))] return [...primary, ...extra.filter(o => !seen.has(o.value))]
} }
// Categories issues de l'embed (fournisseur + adresses), role-independantes. // Categories du formulaire principal (type FOURNISSEUR) : referentiel UNION
const embedCategoryOptions = computed<CategoryOption[]>(() => { // categories embarquees du fournisseur (fallback si referentiel non chargeable).
const fromSupplier = categoryOptionsOf(supplier.value?.categories) const embedSupplierCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(supplier.value?.categories))
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories)) const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedSupplierCategoryOptions.value))
return mergeOptions(fromSupplier, fromAddresses) // Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
}) // embarquees des adresses (meme logique de fallback).
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10). mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value)) )
const addressCategoryOptions = computed(() =>
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
)
const embedSiteOptions = computed<RefOption[]>(() => const embedSiteOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))), mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
@@ -71,7 +71,7 @@
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas <!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). --> sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea <MalioInputTextArea
@@ -137,6 +137,7 @@
:key="contact.id ?? index" :key="contact.id ?? index"
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -151,6 +152,7 @@
:key="view.draft.id ?? index" :key="view.draft.id ?? index"
:model-value="view.draft" :model-value="view.draft"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:category-options="view.categoryOptions" :category-options="view.categoryOptions"
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
@@ -164,8 +166,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': ribs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(accounting.siren)" v-if="isFilled(accounting.siren)"
:model-value="accounting.siren" :model-value="accounting.siren"
@@ -220,13 +224,16 @@
</div> </div>
</div> </div>
<!-- Blocs RIB (0..n), lecture seule. --> <!-- Blocs RIB (0..n), lecture seule.
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in ribs" v-for="(rib, index) in ribs"
:key="rib.id ?? index" :key="rib.id ?? index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== ribs.length - 1 }"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-if="isFilled(rib.label)" v-if="isFilled(rib.label)"
:model-value="rib.label" :model-value="rib.label"
@@ -51,7 +51,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information --> <!-- Onglet Information -->
<template #information> <template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputTextArea <MalioInputTextArea
v-model="information.description" v-model="information.description"
:label="t('commercial.suppliers.form.information.description')" :label="t('commercial.suppliers.form.information.description')"
@@ -145,6 +145,7 @@
:model-value="contact" :model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })" :title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')" :disabled="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -177,7 +178,8 @@
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:category-options="referentials.categories.value" :last="index === addresses.length - 1"
:category-options="referentials.addressCategories.value"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
@@ -210,8 +212,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view) --> <!-- Onglet Comptabilite (present uniquement si accounting.view) -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
@@ -280,21 +284,27 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-2.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('commercial.suppliers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> v-bind="{ ariaLabel: t('commercial.suppliers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
@@ -1,131 +1,140 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : modal de confirmation cote parent. --> (pas de bordure sous le dernier bloc). -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- Suppression : modal de confirmation cote parent. -->
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). --> button-class="p-0"
<MalioSelectCheckbox v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
v-if="!hideEmpty || isFilled(model.siteIris)" @click="$emit('remove')"
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/> />
</div> </div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1"> <!-- Grille 4 colonnes des champs de l'adresse. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.siteIris)"
:model-value="model.siteIris"
:options="siteOptions"
:label="t('technique.providers.form.address.sites')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/>
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
<MalioSelectCheckbox
v-if="!hideEmpty || isFilled(model.contactIris)"
:model-value="model.contactIris"
:options="contactOptions"
:label="t('technique.providers.form.address.contacts')"
:display-tag="true"
:readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<MalioSelect
v-if="!hideEmpty || isFilled(model.country)"
:model-value="model.country"
:options="countryOptions"
:label="t('technique.providers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText <MalioInputText
:model-value="model.streetComplement" v-if="!hideEmpty || isFilled(model.postalCode)"
:label="t('technique.providers.form.address.streetComplement')" :model-value="model.postalCode"
:label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('technique.providers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK" :mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled" :disabled="disabled"
:error="errors?.streetComplement" :required="!readonly && !disabled"
@update:model-value="(v: string) => update('streetComplement', v)" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/> />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('technique.providers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
<MalioInputText
:model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -143,6 +152,8 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: ProviderAddressFormDraft modelValue: ProviderAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Sites Starseed disponibles. */ /** Sites Starseed disponibles. */
siteOptions: RefOption[] siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */ /** Contacts deja saisis, rattachables a l'adresse. */
@@ -150,6 +161,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,84 +1,93 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc) ou en lecture seule. --> <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
v-if="removable && !readonly && !disabled" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
variant="ghost" <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
button-class="absolute top-3 right-3" non supprimable (1er bloc) ou en lecture seule. -->
v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('technique.providers.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('technique.providers.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('technique.providers.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('technique.providers.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText :model-value="model.firstName"
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la :label="t('technique.providers.form.contact.firstName')"
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. --> :mask="PERSON_NAME_MASK"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :readonly="readonly"
<MalioInputText :disabled="disabled"
:model-value="model.jobTitle" :error="errors?.firstName"
:label="t('technique.providers.form.contact.jobTitle')" @update:model-value="(v: string) => update('firstName', v)"
:mask="FREE_TEXT_MASK" />
:readonly="readonly" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
:disabled="disabled" (inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
:error="errors?.jobTitle" cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
@update:model-value="(v: string) => update('jobTitle', v)" <div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('technique.providers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div> </div>
</template> </template>
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{ const props = defineProps<{
/** Brouillon du contact (v-model). */ /** Brouillon du contact (v-model). */
modelValue: ProviderContactFormDraft modelValue: ProviderContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icone de suppression (1er bloc non supprimable). */ /** Affiche l'icone de suppression (1er bloc non supprimable). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet valide). */ /** Bloc en lecture seule (onglet valide). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -46,6 +46,7 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
return mount(ProviderAddressBlock, { return mount(ProviderAddressBlock, {
props: { props: {
modelValue: { ...emptyProviderAddress(), ...overrides }, modelValue: { ...emptyProviderAddress(), ...overrides },
title: 'Adresse 1',
siteOptions: [], siteOptions: [],
contactOptions: [], contactOptions: [],
countryOptions: [], countryOptions: [],
@@ -29,6 +29,7 @@ function mountBlock(errors?: Record<string, string>) {
return mount(ProviderContactBlock, { return mount(ProviderContactBlock, {
props: { props: {
modelValue: emptyProviderContact(), modelValue: emptyProviderContact(),
title: 'Contact 1',
...(errors ? { errors } : {}), ...(errors ? { errors } : {}),
}, },
global: { global: {
@@ -72,7 +72,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="businessReadonly" :disabled="businessReadonly"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -104,6 +106,8 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
@@ -136,8 +140,10 @@
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). --> <!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
@@ -206,21 +212,27 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
@@ -81,6 +81,8 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -94,6 +96,8 @@
v-for="(view, index) in addressViews" v-for="(view, index) in addressViews"
:key="index" :key="index"
:model-value="view.draft" :model-value="view.draft"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addressViews.length - 1"
:site-options="view.siteOptions" :site-options="view.siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptionsFor(view.draft.country)" :country-options="countryOptionsFor(view.draft.country)"
@@ -108,8 +112,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view). --> <!-- Onglet Comptabilite (present uniquement si accounting.view). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled /> <MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled /> <MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" /> <MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
@@ -120,13 +126,16 @@
</div> </div>
</div> </div>
<!-- Blocs RIB (uniquement si type de reglement = LCR). --> <!-- Blocs RIB (uniquement si type de reglement = LCR).
Titre « RIB N », filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled /> <MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled /> <MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled /> <MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
@@ -73,7 +73,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('technique.providers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contact')" :disabled="isValidated('contact')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -108,6 +110,8 @@
v-for="(address, index) in addresses" v-for="(address, index) in addresses"
:key="index" :key="index"
:model-value="address" :model-value="address"
:title="t('technique.providers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
@@ -139,8 +143,10 @@
<!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). --> <!-- Onglet Comptabilite (present uniquement si accounting.view ; editable si manage). -->
<template v-if="canAccountingView" #accounting> <template v-if="canAccountingView" #accounting>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc infos comptables : titre + filet bas (filet uniquement s'il y a des RIB en dessous). -->
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="pb-[20px]" :class="{ 'border-b border-black': visibleRibs.length > 0 }">
<h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.infoTitle') }}</h2>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
@@ -210,21 +216,27 @@
</div> </div>
</div> </div>
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). --> <!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08).
Titre « RIB N » + poubelle, filet de separation sauf sous le dernier. -->
<div <div
v-for="(rib, index) in visibleRibs" v-for="(rib, index) in visibleRibs"
:key="index" :key="index"
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="pb-[20px]"
:class="{ 'border-b border-black': index !== visibleRibs.length - 1 }"
> >
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle a droite. -->
v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ t('technique.providers.form.accounting.ribTitle', { n: index + 1 }) }}</h2>
variant="ghost" <MalioButtonIcon
button-class="absolute top-3 right-3" v-if="!accountingReadonly && isRowRemovable(visibleRibs, index)"
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }" icon="mdi:delete-outline"
@click="askRemoveRib(index)" variant="ghost"
/> button-class="p-0"
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
@click="askRemoveRib(index)"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
@@ -1,103 +1,113 @@
<template> <template>
<!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. --> <!-- Adresse UNIQUE par transporteur (ERP-172) : un seul bloc, jamais supprimable. -->
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Pays : prerempli « France » (RG-4.05). --> (pas de bordure sous le dernier bloc). -->
<MalioSelect <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="!hideEmpty || isFilled(model.country)" <!-- En-tete : titre du bloc, en noir (adresse unique, sans suppression). -->
:model-value="model.country" <div class="flex items-center justify-between">
:options="countryOptions" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
:label="t('transport.carriers.form.address.country')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div> </div>
<MalioInputText <!-- Grille 4 colonnes des champs de l'adresse. -->
v-if="!hideEmpty || isFilled(model.streetComplement)" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
:model-value="model.streetComplement" <!-- Pays : prerempli « France » (RG-4.05). -->
:label="t('transport.carriers.form.address.streetComplement')" <MalioSelect
:mask="ADDRESS_MASK" v-if="!hideEmpty || isFilled(model.country)"
:readonly="readonly" :model-value="model.country"
:disabled="disabled" :options="countryOptions"
:error="errors?.streetComplement" :label="t('transport.carriers.form.address.country')"
@update:model-value="(v: string) => update('streetComplement', v)" :readonly="readonly"
/> :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
<MalioInputText
v-if="!hideEmpty || isFilled(model.postalCode)"
:model-value="model.postalCode"
:label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
<MalioSelect
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:options="cityOptions"
:label="t('transport.carriers.form.address.city')"
:readonly="readonly"
:disabled="disabled"
empty-option-label=""
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
:model-value="model.city"
:label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
consultation masquee (la grille se recompose sans les champs vides). -->
<div v-if="!hideEmpty" aria-hidden="true" />
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly && !disabled"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
:allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('transport.carriers.form.address.street')"
:readonly="readonly"
:disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<MalioInputText
v-if="!hideEmpty || isFilled(model.streetComplement)"
:model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
</div> </div>
</template> </template>
@@ -118,8 +128,12 @@ const POSTAL_CODE_MASK = '#####'
const props = defineProps<{ const props = defineProps<{
/** Brouillon de l'adresse (v-model). */ /** Brouillon de l'adresse (v-model). */
modelValue: CarrierAddressFormDraft modelValue: CarrierAddressFormDraft
/** Titre du bloc (ex: « Adresse 1 »). */
title: string
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean disabled?: boolean
@@ -1,84 +1,93 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si (pas de bordure sous le dernier bloc). -->
non supprimable (1er bloc) ou en lecture seule. --> <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<MalioButtonIcon <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
v-if="removable && !readonly && !disabled" <div class="flex items-center justify-between">
icon="mdi:delete-outline" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
variant="ghost" <!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
button-class="absolute top-3 right-3" non supprimable (1er bloc) ou en lecture seule. -->
v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<MalioInputText button-class="p-0"
v-if="!hideEmpty || isFilled(model.lastName)" v-bind="{ ariaLabel: t('transport.carriers.form.contact.remove') }"
:model-value="model.lastName" @click="$emit('remove')"
:label="t('transport.carriers.form.contact.lastName')" />
:mask="PERSON_NAME_MASK" </div>
:readonly="readonly"
:disabled="disabled" <!-- Grille 4 colonnes des champs du contact. -->
:error="errors?.lastName" <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
@update:model-value="(v: string) => update('lastName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.lastName)"
<MalioInputText :model-value="model.lastName"
v-if="!hideEmpty || isFilled(model.firstName)" :label="t('transport.carriers.form.contact.lastName')"
:model-value="model.firstName" :mask="PERSON_NAME_MASK"
:label="t('transport.carriers.form.contact.firstName')" :readonly="readonly"
:mask="PERSON_NAME_MASK" :disabled="disabled"
:readonly="readonly" :error="errors?.lastName"
:disabled="disabled" @update:model-value="(v: string) => update('lastName', v)"
:error="errors?.firstName" />
@update:model-value="(v: string) => update('firstName', v)" <MalioInputText
/> v-if="!hideEmpty || isFilled(model.firstName)"
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false) :model-value="model.firstName"
renvoie `class` sur l'input interne, pas sur la cellule de grille. --> :label="t('transport.carriers.form.contact.firstName')"
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2"> :mask="PERSON_NAME_MASK"
<MalioInputText :readonly="readonly"
:model-value="model.jobTitle" :disabled="disabled"
:label="t('transport.carriers.form.contact.jobTitle')" :error="errors?.firstName"
:mask="FREE_TEXT_MASK" @update:model-value="(v: string) => update('firstName', v)"
:readonly="readonly" />
:disabled="disabled" <!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
:error="errors?.jobTitle" renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
@update:model-value="(v: string) => update('jobTitle', v)" <div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
<MalioInputText
:model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)"
/>
</div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('transport.carriers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
</div> </div>
<MalioInputEmail
v-if="!hideEmpty || isFilled(model.email)"
:model-value="model.email"
:label="t('transport.carriers.form.contact.email')"
:readonly="readonly"
:disabled="disabled"
:lowercase="true"
:error="errors?.email"
@update:model-value="(v: string) => update('email', v)"
/>
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
<MalioInputPhone
v-if="!hideEmpty || isFilled(model.phonePrimary)"
:model-value="model.phonePrimary"
:label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')"
@update:model-value="(v: string) => update('phonePrimary', v)"
@add="revealSecondaryPhone"
/>
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
<MalioInputPhone
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
:model-value="model.phoneSecondary"
:label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK"
:readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)"
/>
</div> </div>
</template> </template>
@@ -93,8 +102,12 @@ const PHONE_MASK = '## ## ## ## ##'
const props = defineProps<{ const props = defineProps<{
/** Brouillon du contact (v-model). */ /** Brouillon du contact (v-model). */
modelValue: CarrierContactFormDraft modelValue: CarrierContactFormDraft
/** Titre du bloc (ex: « Contact 1 »). */
title: string
/** Affiche l'icône de suppression (1er bloc non supprimable). */ /** Affiche l'icône de suppression (1er bloc non supprimable). */
removable?: boolean removable?: boolean
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Bloc en lecture seule (onglet validé). */ /** Bloc en lecture seule (onglet validé). */
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */ /** Bloc desactive (champs grises, consultation — distinct de readonly). */
@@ -1,190 +1,199 @@
<template> <template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"> <!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
<!-- Suppression : modal de confirmation côté parent. --> (pas de bordure sous le dernier bloc), aligne sur les blocs contact / adresse. -->
<MalioButtonIcon <div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
v-if="removable && !readonly && !disabled" <!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
icon="mdi:delete-outline" <div class="flex items-center justify-between">
variant="ghost" <h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
button-class="absolute top-3 right-3" <!-- Suppression : modal de confirmation côté parent. -->
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }" <MalioButtonIcon
@click="$emit('remove')" v-if="removable && !readonly && !disabled"
/> icon="mdi:delete-outline"
variant="ghost"
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios button-class="p-0"
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
case « Affréter ». Pas de label de groupe. --> @click="$emit('remove')"
<div> />
<div class="flex h-12 items-center gap-6">
<MalioRadioButton
:model-value="model.direction"
:name="`price-direction-${uid}`"
value="CLIENT"
:label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="onDirectionChange"
/>
<MalioRadioButton
:model-value="model.direction"
:name="`price-direction-${uid}`"
value="FOURNISSEUR"
:label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="onDirectionChange"
/>
</div>
<p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
</div> </div>
<!-- Branche CLIENT (RG-4.10). --> <!-- Grille 4 colonnes des champs du prix. -->
<template v-if="model.direction === 'CLIENT'"> <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioSelect <!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
:model-value="model.clientIri" EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
:options="clientOptions" case « Affréter ». Pas de label de groupe. -->
:label="t('transport.carriers.form.price.client')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.client"
@update:model-value="onClientChange"
/>
<MalioSelect
:model-value="model.clientDeliveryAddressIri"
:options="clientAddressOptions"
:label="t('transport.carriers.form.price.clientDeliveryAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.departureSiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.departureSite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Branche FOURNISSEUR (RG-4.11). -->
<template v-else-if="model.direction === 'FOURNISSEUR'">
<MalioSelect
:model-value="model.supplierIri"
:options="supplierOptions"
:label="t('transport.carriers.form.price.supplier')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplier"
@update:model-value="onSupplierChange"
/>
<MalioSelect
:model-value="model.supplierSupplyAddressIri"
:options="supplierAddressOptions"
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.deliverySiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.deliverySite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Communs (visibles dès qu'un sens est choisi). -->
<template v-if="model.direction !== null">
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
<div> <div>
<div class="flex h-12 items-center gap-4"> <div class="flex h-12 items-center gap-6">
<MalioRadioButton <MalioRadioButton
:model-value="model.containerType" :model-value="model.direction"
:name="`price-container-${uid}`" :name="`price-direction-${uid}`"
value="BENNE" value="CLIENT"
:label="t('transport.carriers.containerType.BENNE')" :label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly || disabled" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))" @update:model-value="onDirectionChange"
/> />
<MalioRadioButton <MalioRadioButton
:model-value="model.containerType" :model-value="model.direction"
:name="`price-container-${uid}`" :name="`price-direction-${uid}`"
value="FOND_MOUVANT" value="FOURNISSEUR"
:label="t('transport.carriers.containerType.FOND_MOUVANT')" :label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly || disabled" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))" @update:model-value="onDirectionChange"
/> />
</div> </div>
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p> <p v-if="errors?.direction" class="ml-[2px] text-xs text-m-danger">{{ errors.direction }}</p>
</div> </div>
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). --> <!-- Branche CLIENT (RG-4.10). -->
<div> <template v-if="model.direction === 'CLIENT'">
<div class="flex h-12 items-center gap-4"> <MalioSelect
<MalioRadioButton :model-value="model.clientIri"
:model-value="model.pricingUnit" :options="clientOptions"
:name="`price-unit-${uid}`" :label="t('transport.carriers.form.price.client')"
value="FORFAIT" empty-option-label=""
:label="t('transport.carriers.form.price.pricingForfait')" :required="true"
:disabled="readonly || disabled" :readonly="readonly"
group-class="mt-0" :disabled="disabled"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" :error="errors?.client"
/> @update:model-value="onClientChange"
<MalioRadioButton />
:model-value="model.pricingUnit" <MalioSelect
:name="`price-unit-${uid}`" :model-value="model.clientDeliveryAddressIri"
value="TONNE" :options="clientAddressOptions"
:label="t('transport.carriers.form.price.pricingTonne')" :label="t('transport.carriers.form.price.clientDeliveryAddress')"
:disabled="readonly || disabled" empty-option-label=""
group-class="mt-0" :required="true"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" :readonly="readonly"
/> :disabled="disabled"
:error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.departureSiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.departureSite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Branche FOURNISSEUR (RG-4.11). -->
<template v-else-if="model.direction === 'FOURNISSEUR'">
<MalioSelect
:model-value="model.supplierIri"
:options="supplierOptions"
:label="t('transport.carriers.form.price.supplier')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplier"
@update:model-value="onSupplierChange"
/>
<MalioSelect
:model-value="model.supplierSupplyAddressIri"
:options="supplierAddressOptions"
:label="t('transport.carriers.form.price.supplierSupplyAddress')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/>
<MalioSelect
:model-value="model.deliverySiteIri"
:options="siteOptions"
:label="t('transport.carriers.form.price.deliverySite')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/>
</template>
<!-- Communs (visibles dès qu'un sens est choisi). -->
<template v-if="model.direction !== null">
<!-- Contenant : Benne / Fond mouvant (radios centrés h-12, pas de label). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/>
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/>
</div>
<p v-if="errors?.containerType" class="ml-[2px] text-xs text-m-danger">{{ errors.containerType }}</p>
</div> </div>
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
</div>
<MalioInputAmount <!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
:model-value="model.price" <div>
:label="t('transport.carriers.form.price.price')" <div class="flex h-12 items-center gap-4">
:required="true" <MalioRadioButton
:readonly="readonly" :model-value="model.pricingUnit"
:disabled="disabled" :name="`price-unit-${uid}`"
:error="errors?.price" value="FORFAIT"
@update:model-value="(v: string) => update('price', v)" :label="t('transport.carriers.form.price.pricingForfait')"
/> :disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/>
<MalioRadioButton
:model-value="model.pricingUnit"
:name="`price-unit-${uid}`"
value="TONNE"
:label="t('transport.carriers.form.price.pricingTonne')"
:disabled="readonly || disabled"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/>
</div>
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
</div>
<MalioSelect <MalioInputAmount
:model-value="model.priceState" :model-value="model.price"
:options="priceStateOptions" :label="t('transport.carriers.form.price.price')"
:label="t('transport.carriers.form.price.priceState')" :required="true"
empty-option-label="" :readonly="readonly"
:required="true" :disabled="disabled"
:readonly="readonly" :error="errors?.price"
:disabled="disabled" @update:model-value="(v: string) => update('price', v)"
:error="errors?.priceState" />
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/> <MalioSelect
</template> :model-value="model.priceState"
:options="priceStateOptions"
:label="t('transport.carriers.form.price.priceState')"
empty-option-label=""
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.priceState"
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/>
</template>
</div>
</div> </div>
</template> </template>
@@ -200,6 +209,10 @@ interface SelectOption {
const props = defineProps<{ const props = defineProps<{
/** Brouillon du prix (v-model). */ /** Brouillon du prix (v-model). */
modelValue: CarrierPriceFormDraft modelValue: CarrierPriceFormDraft
/** Titre du bloc (ex: « Prix 1 »). */
title: string
/** Dernier bloc de la liste : supprime le filet de separation bas. */
last?: boolean
/** Clients disponibles (IRI en value). */ /** Clients disponibles (IRI en value). */
clientOptions: SelectOption[] clientOptions: SelectOption[]
/** Fournisseurs disponibles (IRI en value). */ /** Fournisseurs disponibles (IRI en value). */
@@ -56,6 +56,7 @@ function mountBlock(overrides: Record<string, unknown> = {}) {
return mount(CarrierAddressBlock, { return mount(CarrierAddressBlock, {
props: { props: {
modelValue: { ...emptyCarrierAddress(), ...overrides }, modelValue: { ...emptyCarrierAddress(), ...overrides },
title: 'Adresse 1',
countryOptions: [{ value: 'France', label: 'France' }], countryOptions: [{ value: 'France', label: 'France' }],
}, },
global: { global: {
@@ -143,6 +143,8 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. --> <!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions" :country-options="countryOptions"
:errors="addressErrors" :errors="addressErrors"
@update:model-value="(v) => address = v" @update:model-value="(v) => address = v"
@@ -160,7 +162,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -178,10 +182,12 @@
v-for="(price, index) in prices" v-for="(price, index) in prices"
:key="index" :key="index"
:model-value="price" :model-value="price"
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
:client-options="clientOptions" :client-options="clientOptions"
:supplier-options="supplierOptions" :supplier-options="supplierOptions"
:site-options="siteOptions" :site-options="siteOptions"
removable removable
:last="index === prices.length - 1"
:errors="priceErrors[index]" :errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v" @update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)" @remove="askRemovePrice(index)"
@@ -123,6 +123,8 @@
<!-- Adresse UNIQUE (ERP-172). --> <!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptionsFor(address.country)" :country-options="countryOptionsFor(address.country)"
disabled disabled
hide-empty hide-empty
@@ -136,6 +138,8 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:last="index === contacts.length - 1"
disabled disabled
hide-empty hide-empty
/> />
@@ -180,6 +180,8 @@
<!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. --> <!-- Adresse UNIQUE (ERP-172) : un seul bloc, sans ajouter/supprimer. -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:title="t('transport.carriers.form.address.title')"
:last="true"
:country-options="countryOptions" :country-options="countryOptions"
:disabled="isQualimat || isValidated('addresses')" :disabled="isQualimat || isValidated('addresses')"
:errors="addressErrors" :errors="addressErrors"
@@ -207,7 +209,9 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:title="t('transport.carriers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:last="index === contacts.length - 1"
:disabled="isValidated('contacts')" :disabled="isValidated('contacts')"
:errors="contactErrors[index]" :errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v" @update:model-value="(v) => contacts[index] = v"
@@ -240,11 +244,13 @@
v-for="(price, index) in prices" v-for="(price, index) in prices"
:key="index" :key="index"
:model-value="price" :model-value="price"
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
:client-options="clientOptions" :client-options="clientOptions"
:supplier-options="supplierOptions" :supplier-options="supplierOptions"
:site-options="siteOptions" :site-options="siteOptions"
:removable="!isValidated('prices')" :removable="!isValidated('prices')"
:disabled="isValidated('prices')" :disabled="isValidated('prices')"
:last="index === prices.length - 1"
:errors="priceErrors[index]" :errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v" @update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)" @remove="askRemovePrice(index)"
+10 -4
View File
@@ -35,7 +35,7 @@ export interface Persona {
// sidebar-visibility pour driver la matrice. Les valeurs correspondent // sidebar-visibility pour driver la matrice. Les valeurs correspondent
// aux slugs de route (`/admin/<slug>`), volontairement stables quand // aux slugs de route (`/admin/<slug>`), volontairement stables quand
// la copie/i18n change. // la copie/i18n change.
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'> expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'>
} }
const SHARED_PASSWORD = 'e2e-secret' const SHARED_PASSWORD = 'e2e-secret'
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
password: SHARED_PASSWORD, password: SHARED_PASSWORD,
isAdmin: true, isAdmin: true,
permissions: [], permissions: [],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
}, },
'user-full': { 'user-full': {
key: 'user-full', key: 'user-full',
@@ -65,6 +65,12 @@ export const personas: Record<PersonaKey, Persona> = {
'sites.bypass_scope', 'sites.bypass_scope',
'catalog.categories.view', 'catalog.categories.view',
'catalog.categories.manage', 'catalog.categories.manage',
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx p.3) :
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
// n°7). L'item vit dans la section Administration sur la route
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
'catalog.products.view',
'catalog.products.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le persona // Commercial — Repertoire clients (M1). Mappe ici sur le persona
// "tout" en attendant les vrais roles metier (bureau/compta/ // "tout" en attendant les vrais roles metier (bureau/compta/
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona // commerciale/usine) seedes par ERP-74. Pas de nouveau persona
@@ -110,7 +116,7 @@ export const personas: Record<PersonaKey, Persona> = {
'logistique.weighing_tickets.view', 'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage', 'logistique.weighing_tickets.manage',
], ],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'], expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
}, },
'user-readonly': { 'user-readonly': {
key: 'user-readonly', key: 'user-readonly',
@@ -155,4 +161,4 @@ export function getPersona(key: PersonaKey): Persona {
return personas[key] return personas[key]
} }
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const
+1
View File
@@ -233,6 +233,7 @@ test-db-setup:
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL"
fixtures: fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Taxonomie ADRESSE (module Catalog) categories du champ « Categorie » des blocs adresse.
*
* Contexte : jusqu'ici le multi-select « Categorie » des blocs adresse reutilisait
* la taxonomie CLIENT (M1, codes DISTRIBUTEUR/COURTIER blacklistes par RG-1.29) ou
* FOURNISSEUR (M2, RG-2.10). On introduit un type dedie ADRESSE : les blocs adresse
* client (ClientAddress) et fournisseur (SupplierAddress) ne referencent plus que
* des `Category` rattachees au type ADRESSE (validation whitelist par type).
*
* Cette migration :
* 1. cree le `category_type` ADRESSE (code ADRESSE, label « Adresse ») ;
* 2. seede 6 `Category` rattachees a ce type via la jonction ManyToMany
* `category_category_type` (modele courant depuis Version20260608120000 ;
* la colonne ManyToOne `category.category_type_id` n'existe plus).
*
* Aucune colonne creee/modifiee -> pas de `COMMENT ON COLUMN` (regle ABSOLUE n°12) :
* la migration ne fait que des INSERT de donnees de reference.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* garantit l'ordre par timestamp avant les migrations modulaires sur base vide.
*
* Idempotence : `INSERT ... ON CONFLICT (code) DO NOTHING` pour le type,
* `INSERT ... SELECT ... WHERE NOT EXISTS` pour chaque categorie et chaque ligne
* de jonction (aligne sur le pattern PRESTATAIRE / Version20260612080000). En prod
* la table `category` est vide (aucune fixture metier) ; en dev/test le purger
* Doctrine vide `category` / `category_type` avant les fixtures qui reproduisent le
* meme etat final (CategoryTypeFixtures / CategoryFixtures etendus a ADRESSE).
*/
final class Version20260625100000 extends AbstractMigration
{
/**
* Categories de demonstration du type ADRESSE : nom => code stable. Le code est
* la cle metier (slug MAJUSCULE du nom, miroir du CategoryCodeGenerator) et reste
* unique parmi les actifs (uq_category_code). Le nom est unique GLOBALEMENT parmi
* les actifs (uq_category_name_active) : aucune collision avec les categories
* deja seedees (CLIENT / FOURNISSEUR / PRESTATAIRE).
*/
private const array ADDRESS_CATEGORIES = [
'Siège' => 'SIEGE',
'Contact issues' => 'CONTACT_ISSUES',
'Facturation' => 'FACTURATION',
'Livraison' => 'LIVRAISON',
'Approvisionnement' => 'APPROVISIONNEMENT',
'Méthaniseur' => 'METHANISEUR',
];
public function getDescription(): string
{
return 'Taxonomie ADRESSE : cree le CategoryType ADRESSE + seed des categories adresse (Siege, Contact issues, Facturation, Livraison, Approvisionnement, Methaniseur).';
}
public function up(Schema $schema): void
{
// 1. Type ADRESSE (idempotent via l'index unique uq_category_type_code).
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES ('ADRESSE', 'Adresse')
ON CONFLICT (code) DO NOTHING
SQL);
foreach (self::ADDRESS_CATEGORIES as $name => $code) {
// 2a. Categorie sous ADRESSE (si le code est libre parmi les actifs).
// created_at/updated_at NOT NULL -> NOW() ; le blame reste null
// (seed hors contexte HTTP, libelle « Systeme » cote front).
$this->addSql(<<<'SQL'
INSERT INTO category (name, code, created_at, updated_at)
SELECT :name, :code, NOW(), NOW()
WHERE NOT EXISTS (
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
)
SQL, ['name' => $name, 'code' => $code]);
// 2b. Jonction M2M categorie <-> type ADRESSE (modele courant).
$this->addSql(<<<'SQL'
INSERT INTO category_category_type (category_id, category_type_id)
SELECT c.id, ct.id
FROM category c
CROSS JOIN category_type ct
WHERE c.code = :code AND c.deleted_at IS NULL
AND ct.code = 'ADRESSE'
AND NOT EXISTS (
SELECT 1 FROM category_category_type cct
WHERE cct.category_id = c.id AND cct.category_type_id = ct.id
)
SQL, ['code' => $code]);
}
}
public function down(Schema $schema): void
{
// Best-effort : on retire d'abord les categories seedees (par code) — la FK
// category_category_type est ON DELETE CASCADE cote category, donc les lignes
// de jonction partent avec —, puis le type s'il n'est plus reference.
$this->addSql(
'DELETE FROM category WHERE code IN (:codes) '
.'AND id IN (SELECT category_id FROM category_category_type cct '
."JOIN category_type ct ON ct.id = cct.category_type_id WHERE ct.code = 'ADRESSE')",
['codes' => array_values(self::ADDRESS_CATEGORIES)],
['codes' => ArrayParameterType::STRING],
);
$this->addSql(<<<'SQL'
DELETE FROM category_type
WHERE code = 'ADRESSE'
AND NOT EXISTS (
SELECT 1 FROM category_category_type cct WHERE cct.category_type_id = category_type.id
)
SQL);
}
}
+263
View File
@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M6 Catalogue produit (ERP-198) : creation du schema BDD du module.
*
* Objets crees (spec-back § 3.2) :
* - storage_type : referentiel PROVISOIRE des types de stockage (en attente de la
* liste definitive d'Aurore § 2.4 / RG-6.06). Lecture seule au M6.
* - storage_type_site : jonction M2M storage_type <-> site (sur quels sites un type
* de stockage est disponible alimente le filtrage du multi-select par site).
* - product : table principale (code unique global parmi les actifs, etats
* multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT,
* soft-delete prepare + Timestampable/Blamable).
* - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04).
* - product_storage_type : jonction M2M product <-> storage_type (RG-6.06).
*
* Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme
* CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE § 2.5). Les `Category` de type PRODUIT et
* le seed Figma du referentiel storage_type suivent au ticket ERP-201.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
* la table product porte des FK cross-module (user, site, category). Le tri par
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
* tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf.
* Version20260617150000 pour le M5).
*
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
* `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12).
*
* NB schema:update (test-db-setup) : product / storage_type et leurs jonctions seront
* mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la,
* `schema:update --force` les drope sur la base de TEST uniquement (sans impact :
* aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update).
* Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme
* weighing_ticket : migration ERP-182, catalogue ERP-183).
*/
final class Version20260625110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.';
}
public function up(Schema $schema): void
{
$this->createStorageType();
$this->createStorageTypeSite();
$this->createProduct();
$this->createProductSite();
$this->createProductStorageType();
$this->seedCategoryTypeProduit();
}
public function down(Schema $schema): void
{
// Ordre inverse des dependances FK.
$this->addSql('DROP TABLE IF EXISTS product_storage_type');
$this->addSql('DROP TABLE IF EXISTS product_site');
$this->addSql('DROP TABLE IF EXISTS product');
$this->addSql('DROP TABLE IF EXISTS storage_type_site');
$this->addSql('DROP TABLE IF EXISTS storage_type');
// Retrait du type seede (best-effort : echoue si des categories le referencent
// encore — attendu, le down sert au dev sur base saine).
$this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'");
}
// =================================================================
// Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06
// =================================================================
private function createStorageType(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage_type (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(40) NOT NULL,
label VARCHAR(120) NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code)');
$this->comment('storage_type', '_table', 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.');
$this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.');
$this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).');
$this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).');
}
private function createStorageTypeSite(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage_type_site (
storage_type_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (storage_type_id, site_id),
CONSTRAINT fk_storage_type_site_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE,
CONSTRAINT fk_storage_type_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
)
SQL);
$this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)');
$this->comment('storage_type_site', '_table', 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).');
$this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.');
$this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.');
}
// =================================================================
// Table principale `product`
// =================================================================
private function createProduct(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE product (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
states JSONB DEFAULT '[]'::jsonb NOT NULL,
manufactured BOOLEAN DEFAULT FALSE NOT NULL,
contains_molasses BOOLEAN DEFAULT FALSE NOT NULL,
category_id INT NOT NULL,
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
created_by INT DEFAULT NULL,
updated_by INT DEFAULT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_product_states_not_empty
CHECK (jsonb_array_length(states) >= 1),
CONSTRAINT fk_product_category
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT,
CONSTRAINT fk_product_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_product_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel.
$this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL');
$this->addSql('CREATE INDEX idx_product_category ON product (category_id)');
$this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)');
$this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)');
$this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)');
$this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.');
$this->comment('product', 'id', 'Identifiant interne auto-incremente.');
$this->comment('product', 'code', 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).');
$this->comment('product', 'name', 'Nom du produit (≤ 255). Normalise serveur (trim).');
$this->comment('product', 'states', 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.');
$this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
$this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
$this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).');
$this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.');
$this->addTimestampableBlamableComments('product');
}
private function createProductSite(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE product_site (
product_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (product_id, site_id),
CONSTRAINT fk_product_site_product
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
CONSTRAINT fk_product_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)');
$this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).');
$this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
$this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.');
}
private function createProductStorageType(): void
{
$this->addSql(<<<'SQL'
CREATE TABLE product_storage_type (
product_id INT NOT NULL,
storage_type_id INT NOT NULL,
PRIMARY KEY (product_id, storage_type_id),
CONSTRAINT fk_product_storage_type_product
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
CONSTRAINT fk_product_storage_type_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT
)
SQL);
$this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)');
$this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).');
$this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
$this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.');
}
// =================================================================
// Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
// =================================================================
private function seedCategoryTypeProduit(): void
{
// Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/
// PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201.
$this->addSql(<<<'SQL'
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
ON CONFLICT (code) DO NOTHING
SQL);
}
// =================================================================
// Helpers (identiques au M5 Version20260617150000)
// =================================================================
/**
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
* en reutilisant le catalogue partage (source unique, ERP-67).
*/
private function addTimestampableBlamableComments(string $table): void
{
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
$this->comment($table, $column, $description);
}
}
/**
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
*/
private function comment(string $table, string $column, string $description): void
{
$quotedTable = '"'.str_replace('"', '""', $table).'"';
if ('_table' === $column) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
return;
}
$this->addSql(sprintf(
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
$quotedTable,
'"'.str_replace('"', '""', $column).'"',
$description,
));
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Service;
/**
* Normalisation serveur des champs texte d'un Product, appliquee par le
* ProductProcessor AVANT l'unicite du code et la persistance (RG-6.07, spec-back
* M6 § 6). Jumeau du CarrierFieldNormalizer (M4), recentre sur les deux champs
* texte du produit.
*
* - code : trim + UPPER (cohérent avec la stratégie de codes stables du Catalog
* le code produit fait office de cle metier saisie, unique global parmi les
* actifs RG-6.01).
* - name : trim simple (pas de changement de casse libelle affiche).
*
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
* trim devient null. En pratique le ProductProcessor n'appelle ces methodes
* qu'apres validation (NotBlank deja joue par API Platform), donc le code et le
* name sont non vides a ce stade le retour null reste un garde-fou.
*/
final class ProductFieldNormalizer
{
/**
* Code produit en majuscules (RG-6.07) : " ble-01 " -> "BLE-01". Conserve
* null tel quel ; une chaine vide apres trim devient null (c'est l'Assert\NotBlank
* de l'entite qui rejette le vide, pas le normalizer).
*/
public function normalizeCode(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : mb_strtoupper($value, 'UTF-8');
}
/**
* Nom du produit trimme (RG-6.07), sans changement de casse. Une chaine vide
* apres trim devient null.
*/
public function normalizeName(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
}
+4
View File
@@ -43,6 +43,10 @@ final class CatalogModule
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue // sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7. // dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'], ['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
// Catalogue produit (M6, ERP-197) : admin-only (matrice docx p.3, C7).
// Item sidebar dans la section Administration, sous « Repertoire transporteurs ».
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
]; ];
} }
} }
@@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function in_array;
/**
* Produit du catalogue (M6 Catalog) entite racine du module produit, jumelle de
* Category (#[Auditable], TimestampableBlamable, soft-delete) cote pattern et de
* Carrier (M4) / WeighingTicket (M5) cote contrat de serialisation (RETEX M1,
* 3 maillons spec § 4.0).
*
* Contrat de serialisation :
* - LISTE (product:read + category:read + site:read + storage_type:read +
* default:read) : code (« Numero »), name, states, manufactured,
* containsMolasses, category embarquee, createdAt/updatedAt (via default:read).
* - DETAIL (+ product:item:read) : ajoute sites + storageTypes embarques
* (ensembles bornes -> embed autorise, ne viole pas la regle n°13). Le groupe
* product:item:read est reserve pour d'eventuels champs detail-only ulterieurs.
*
* Regles de gestion (renvoyees au Processor/Provider, ERP-200) :
* - RG-6.01 : `code` unique global parmi les actifs, normalise serveur (trim/UPPER),
* 409 sur doublon (index partiel uq_product_code_active).
* - RG-6.02 : `states` = sous-ensemble non vide de {PURCHASE, SALE, OTHER}.
* - RG-6.03 : `manufactured` / `containsMolasses` saisis uniquement si states
* contient SALE, sinon forces false serveur.
* - RG-6.04 : `sites` >= 1.
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
* - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes.
*
* Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete
* dans les operations, la liste exclut les produits supprimes (Provider, ERP-200).
*
* Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
* porte un propertyPath exploitable par useFormErrors mapping inline, ERP-101).
*
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee
* (§ 2.1) on reutilise son read-group `site:read`, sans logique inter-module.
* `Category` et `StorageType` sont dans le meme module Catalog.
*
* @see ProductProvider Lecture (liste paginee filtree soft-delete + item) ERP-200.
* @see ProductProcessor Ecriture (normalisation, unicite code, RG-6.03/05/06) ERP-200.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
provider: ProductProvider::class,
),
new Get(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
provider: ProductProvider::class,
),
new Post(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
processor: ProductProcessor::class,
),
new Patch(
security: "is_granted('catalog.products.manage')",
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['product:write']],
provider: ProductProvider::class,
processor: ProductProcessor::class,
),
// Pas de Delete au M6 (docx) ; soft-delete prepare non expose (§ 2.7).
],
)]
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
#[ORM\Table(name: 'product')]
// Index nommes pour matcher la migration (cf. Category). L'index unique partiel
// `uq_product_code_active` (code WHERE deleted_at IS NULL — unicite GLOBALE du
// code parmi les actifs, RG-6.01) reste possede par la seule migration :
// Doctrine ORM ne sait pas exprimer un index partiel via attribut.
#[ORM\Index(name: 'idx_product_category', columns: ['category_id'])]
#[ORM\Index(name: 'idx_product_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_product_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_product_updated_by', columns: ['updated_by'])]
#[Auditable]
class Product implements TimestampableInterface, BlamableInterface
{
// === Timestampable + Blamable ===
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
// getters/setters viennent du Trait Shared, remplies automatiquement par le
// TimestampableBlamableSubscriber au prePersist / preUpdate.
use TimestampableBlamableTrait;
/** Etats du produit (RG-6.02) — valeurs autorisees de la colonne JSONB `states`. */
public const string STATE_PURCHASE = 'PURCHASE';
public const string STATE_SALE = 'SALE';
public const string STATE_OTHER = 'OTHER';
/** Code de type de categorie autorise pour un produit (RG-6.05). */
private const string PRODUCT_CATEGORY_TYPE_CODE = 'PRODUIT';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['product:read'])]
private ?int $id = null;
// Code produit (= « Numero » de la liste), saisi, unique global parmi les
// actifs (RG-6.01). Normalise serveur (trim/UPPER) par le ProductProcessor.
#[ORM\Column(length: 50)]
#[Assert\NotBlank(message: 'Le code produit est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['product:read', 'product:write'])]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['product:read', 'product:write'])]
private ?string $name = null;
/**
* Etats du produit (multi-select), sous-ensemble non vide de
* {PURCHASE, SALE, OTHER} (RG-6.02). Stocke en JSONB (tableau de chaines),
* non-vacuite garantie aussi par le CHECK chk_product_states_not_empty.
*
* Validation des valeurs via Assert\Choice(multiple: true) plutot que
* Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par
* le garde-fou EntityConstraintsHaveFrenchMessageTest.
*
* @var list<string>
*/
#[ORM\Column(type: 'json')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
#[Assert\Choice(
choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER],
multiple: true,
message: 'État de produit invalide.',
multipleMessage: 'État de produit invalide.',
)]
#[Groups(['product:read', 'product:write'])]
private array $states = [];
// « Fabrique » : saisi uniquement si states contient SALE, sinon force false
// serveur (RG-6.03).
#[ORM\Column(options: ['default' => false])]
#[Groups(['product:read', 'product:write'])]
private bool $manufactured = false;
// « Contient de la melasse » : saisi uniquement si states contient SALE,
// sinon force false serveur (RG-6.03).
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
#[Groups(['product:read', 'product:write'])]
private bool $containsMolasses = false;
// Categorie produit (obligatoire). Limitee aux categories de type PRODUIT,
// validee applicativement (RG-6.05, Callback ERP-200). FK ON DELETE RESTRICT :
// une categorie referencee par un produit ne peut etre supprimee.
#[ORM\ManyToOne(targetEntity: Category::class)]
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
#[Groups(['product:read', 'product:write'])]
private ?Category $category = null;
/**
* Sites de disponibilite du produit (>= 1, RG-6.04). Relation ORM partagee
* vers Site (module Sites, § 2.1). Cote inverse en ON DELETE RESTRICT : un
* site reference par un produit ne peut etre supprime.
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'product_site')]
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
#[Groups(['product:read', 'product:write'])]
private Collection $sites;
/**
* Types de stockage du produit (>= 1, RG-6.06), filtres par les sites
* selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE
* RESTRICT : un type de stockage reference par un produit ne peut etre supprime.
*
* @var Collection<int, StorageType>
*/
#[ORM\ManyToMany(targetEntity: StorageType::class)]
#[ORM\JoinTable(name: 'product_storage_type')]
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
#[Groups(['product:read', 'product:write'])]
private Collection $storageTypes;
/**
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
* Non expose au M6 (§ 2.7, aucun groupe) : prepare pour une future suppression
* (HP-M6-04). La liste exclut par defaut les produits supprimes (Provider).
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->sites = new ArrayCollection();
$this->storageTypes = new ArrayCollection();
}
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;
}
/**
* @return list<string>
*/
public function getStates(): array
{
return $this->states;
}
/**
* @param list<string> $states
*/
public function setStates(array $states): static
{
$this->states = $states;
return $this;
}
public function isManufactured(): bool
{
return $this->manufactured;
}
public function setManufactured(bool $manufactured): static
{
$this->manufactured = $manufactured;
return $this;
}
public function containsMolasses(): bool
{
return $this->containsMolasses;
}
public function setContainsMolasses(bool $containsMolasses): static
{
$this->containsMolasses = $containsMolasses;
return $this;
}
public function getCategory(): ?Category
{
return $this->category;
}
public function setCategory(?Category $category): static
{
$this->category = $category;
return $this;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(Site $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(Site $site): static
{
$this->sites->removeElement($site);
return $this;
}
/**
* @return Collection<int, StorageType>
*/
public function getStorageTypes(): Collection
{
return $this->storageTypes;
}
public function addStorageType(StorageType $storageType): static
{
if (!$this->storageTypes->contains($storageType)) {
$this->storageTypes->add($storageType);
}
return $this;
}
public function removeStorageType(StorageType $storageType): static
{
$this->storageTypes->removeElement($storageType);
return $this;
}
public function getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
/**
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Validee
* applicativement (pas de contrainte SQL au referentiel, § 2.5) via Callback
* + ->atPath('category') pour que la 422 porte un propertyPath consommable par
* useFormErrors (mapping inline, ERP-101). Le NotNull gere l'absence : on ne
* leve que si une categorie est presente ET non-PRODUIT.
*/
#[Assert\Callback]
public function validateCategoryIsProductType(ExecutionContextInterface $context): void
{
if (null === $this->category) {
return;
}
if (!in_array(self::PRODUCT_CATEGORY_TYPE_CODE, $this->category->getCategoryTypeCodes(), true)) {
$context->buildViolation('La catégorie sélectionnée doit être de type Produit.')
->atPath('category')
->addViolation()
;
}
}
/**
* RG-6.06 : chaque type de stockage choisi doit etre disponible sur AU MOINS UN
* des sites choisis (intersection non vide). Validee via Callback +
* ->atPath('storageTypes'). On ne croise que si les deux collections sont non
* vides : leur absence est deja couverte par les Assert\Count(min: 1) dedies.
*/
#[Assert\Callback]
public function validateStorageTypesAvailableOnSelectedSites(ExecutionContextInterface $context): void
{
if ($this->sites->isEmpty() || $this->storageTypes->isEmpty()) {
return;
}
// Ensemble des ids de sites selectionnes (lookup O(1)).
$selectedSiteIds = [];
foreach ($this->sites as $site) {
$selectedSiteIds[$site->getId()] = true;
}
foreach ($this->storageTypes as $storageType) {
$available = false;
foreach ($storageType->getSites() as $storageTypeSite) {
if (isset($selectedSiteIds[$storageTypeSite->getId()])) {
$available = true;
break;
}
}
if (!$available) {
$context->buildViolation('Le type de stockage « {{ label }} » n\'est disponible sur aucun des sites sélectionnés.')
->setParameter('{{ label }}', (string) $storageType->getLabel())
->atPath('storageTypes')
->addViolation()
;
}
}
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste et
* le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma
* (node 1503-34285) au ticket ERP-201.
*
* Relation `sites` (ManyToMany -> Site) : sites sur lesquels ce type de stockage
* est disponible. Sert au filtrage du multi-select « Type de stockage » par les
* sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6
* (le filtrage est applique cote provider en ERP-201).
*
* Lecture seule au M6 : seules les operations GetCollection et Get sont exposees
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
* (referentiel servant le formulaire produit § 4.2).
*
* Referentiel statique : pas de Timestampable/Blamable ni `#[Auditable]`
* (whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir
* CategoryType cree par migration/seed, pas pilote utilisateur). Le groupe
* `storage_type:read` est porte par chaque propriete affichee pour que le type
* soit embarque dans la reponse d'un Product (cf. .claude/rules/backend.md
* § Serialization).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['storage_type:read']],
// Tri alphabetique stable pour alimenter le multi-select du formulaire
// produit (§ 4.2). Le filtre ?siteId[]= est branche en ERP-201.
order: ['label' => 'ASC'],
),
new Get(
security: "is_granted('catalog.products.view')",
normalizationContext: ['groups' => ['storage_type:read']],
),
],
)]
#[ORM\Entity(repositoryClass: DoctrineStorageTypeRepository::class)]
#[ORM\Table(name: 'storage_type')]
// Contrainte d'unicite nommee pour matcher la migration (cf. CategoryType).
#[ORM\UniqueConstraint(name: 'uq_storage_type_code', columns: ['code'])]
class StorageType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['storage_type:read'])]
private ?int $id = null;
#[ORM\Column(length: 40)]
#[Groups(['storage_type:read'])]
private ?string $code = null;
#[ORM\Column(length: 120)]
#[Groups(['storage_type:read'])]
private ?string $label = null;
/**
* Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non
* exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=`
* du referentiel (branche en ERP-201).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class)]
#[ORM\JoinTable(name: 'storage_type_site')]
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
private Collection $sites;
public function __construct()
{
$this->sites = new ArrayCollection();
}
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 getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
public function addSite(Site $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
}
return $this;
}
public function removeSite(Site $site): static
{
$this->sites->removeElement($site);
return $this;
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Product;
use Doctrine\ORM\QueryBuilder;
interface ProductRepositoryInterface
{
public function findById(int $id): ?Product;
public function save(Product $product): void;
/**
* Vrai si un produit actif (deleted_at IS NULL) porte deja ce code.
* `$excludeId` exclut un produit precis du test (cas PATCH). Garantit
* l'unicite GLOBALE du code parmi les actifs (RG-6.01, index partiel
* uq_product_code_active). Un code reutilisable apres soft-delete (le test
* ignore les supprimes).
*/
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
/**
* QueryBuilder de la liste produits (consomme par le ProductProvider) : exclut
* par defaut les soft-deleted (RG-6.09), trie par name ASC (defaut spec § 4.1)
* et applique les filtres optionnels du drawer « Filtrer » :
* - `$search` : recherche partielle case-insensitive sur `code` + `name`.
* - `$categoryId` : restreint a une categorie precise (par id).
* - `$categoryCode` : restreint a une categorie precise (par code stable).
* - `$state` : appartenance a la colonne JSONB `states` (PURCHASE|SALE|OTHER).
* - `$siteIds` : produit disponible sur AU MOINS UN des sites passes.
*
* @param list<int> $siteIds
*/
public function createListQueryBuilder(
bool $includeDeleted = false,
?string $search = null,
?int $categoryId = null,
?string $categoryCode = null,
?string $state = null,
array $siteIds = [],
): QueryBuilder;
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\StorageType;
interface StorageTypeRepositoryInterface
{
public function findById(int $id): ?StorageType;
/**
* Tous les types de stockage tries par libelle (alimente le multi-select du
* formulaire produit § 4.2). Le filtrage par site (?siteId[]=, RG-6.06) est
* branche cote provider en ERP-201.
*
* @return list<StorageType>
*/
public function findAllOrderedByLabel(): array;
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Catalog\Application\Service\ProductFieldNormalizer;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
use function in_array;
use function sprintf;
/**
* Processor d'ecriture du produit (M6, POST / PATCH). Cf. spec-back M6 § 4.3 /
* § 4.4 + RG-6.01 / RG-6.03 / RG-6.07. Jumeau du CategoryProcessor (409 doublon)
* et du CarrierProcessor (normalisation serveur).
*
* Sequence (POST / PATCH) :
* 1. Normalisation serveur (RG-6.07) via ProductFieldNormalizer : code trim+UPPER,
* name trim. Jouee AVANT l'unicite et la persistance ; la validation
* (NotBlank/Length + Callback RG-6.05/6.06) a deja joue cote API Platform sur
* la saisie brute.
* 2. RG-6.03 : champs conditionnels SALE. Si `states` ne contient pas SALE,
* `manufactured` et `containsMolasses` sont forces false serveur (ils ne sont
* saisissables que si l'etat contient SALE).
* 3. RG-6.01 : unicite GLOBALE du `code` parmi les actifs. Pre-check deterministe
* (excluant le produit courant en PATCH) -> 409 ; l'index partiel
* uq_product_code_active reste le filet anti-race au flush.
* 4. Persistance via le persist_processor Doctrine ORM.
*
* Mode strict PATCH (RETEX M1) : la security d'operation exige deja
* `catalog.products.manage` pour TOUS les champs ecrivables (un seul niveau de
* permission au M6 § 5.2 admin-only). Il n'existe donc aucun champ « hors-permission »
* a re-gater finement (contrairement a l'archivage Carrier RG-4.14 ou au split
* comptable Client RG-1.28) : le 403 global est porte par la security d'operation,
* pas par un guard de champ ici.
*
* Les RG inter-champs RG-6.05 (categorie de type PRODUIT) et RG-6.06 (types de
* stockage disponibles sur les sites choisis) sont portees par des Assert\Callback
* + ->atPath() sur l'entite Product (jouees par API Platform AVANT ce processor),
* pour que chaque 422 porte un propertyPath consommable par useFormErrors (mapping
* inline, pas un toast convention ERP-101).
*
* @implements ProcessorInterface<Product, Product>
*/
final class ProductProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
private readonly ProductFieldNormalizer $normalizer,
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
private readonly ProductRepositoryInterface $repository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Product) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
// 1. RG-6.07 : normalisation serveur (code trim+UPPER, name trim).
$this->normalize($data);
// 2. RG-6.03 : si l'etat ne contient pas SALE, les champs conditionnels
// « Fabrique » / « Contient de la melasse » sont forces false serveur.
if (!in_array(Product::STATE_SALE, $data->getStates(), true)) {
$data->setManufactured(false);
$data->setContainsMolasses(false);
}
// 3. RG-6.01 : unicite GLOBALE du code parmi les actifs (exclut le produit
// courant en PATCH). Pre-check explicite -> 409 deterministe.
$code = (string) $data->getCode();
if ('' !== $code && $this->repository->existsActiveByCode($code, $data->getId())) {
throw $this->duplicateCodeConflict($code);
}
// 4. Persistance, avec filet anti-race sur l'index partiel.
try {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} catch (UniqueConstraintViolationException $e) {
// Insertion concurrente du meme code entre le pre-check et le flush
// (collision sur uq_product_code_active — unicite parmi les actifs).
throw $this->duplicateCodeConflict($code, $e);
}
}
/**
* Normalisation serveur du produit (RG-6.07). Les setters ne sont touches que si
* une valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH
* partiel. Les casts (string) sont surs : NotBlank a deja rejete le vide en amont.
*/
private function normalize(Product $data): void
{
if (null !== $data->getCode()) {
$data->setCode((string) $this->normalizer->normalizeCode($data->getCode()));
}
if (null !== $data->getName()) {
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
}
}
/**
* RG-6.01 : 409 sur doublon de code produit. Le front mappe ce conflit sur le
* champ `code` (setError('code', ...) + toast convention useFormErrors ERP-101
* / useCategoryForm RG-1.07) : le propertyPath exploitable est `code`.
*/
private function duplicateCodeConflict(string $code, ?Throwable $previous = null): ConflictHttpException
{
return new ConflictHttpException(
sprintf('Le code produit « %s » est déjà utilisé par un autre produit.', $code),
$previous,
);
}
}
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Doctrine\Orm\Paginator;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function in_array;
use function is_int;
use function is_string;
/**
* Provider Product (lecture, ERP-200) :
* - LISTE : exclut par defaut les produits soft-deleted (RG-6.09), trie par
* name ASC (defaut spec § 4.1), applique les filtres du drawer « Filtrer »
* (?search, ?categoryId / ?categoryCode, ?state, ?siteId[]) et renvoie une
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
* operation de collection on enveloppe le QueryBuilder dans le Paginator ORM).
* Echappatoire ?pagination=false respectee (alimentation d'un select).
* - ITEM : recharge le produit puis renvoie null (404) s'il est soft-deleted
* le soft-delete n'est jamais expose au M6 (§ 2.7), aucun flag includeDeleted.
*
* @implements ProviderInterface<Product>
*/
final class ProductProvider implements ProviderInterface
{
/** Etats valides du filtre ?state= (enum borne, RG-6.02). */
private const array VALID_STATES = [Product::STATE_PURCHASE, Product::STATE_SALE, Product::STATE_OTHER];
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
private readonly ProductRepositoryInterface $repository,
private readonly Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Product|null
{
if ($operation instanceof CollectionOperationInterface) {
// includeDeleted toujours false : le soft-delete n'est pas expose au M6.
$qb = $this->repository->createListQueryBuilder(
false,
$this->readSearch($context),
$this->readCategoryId($context),
$this->readCategoryCode($context),
$this->readState($context),
$this->readSiteIds($context),
);
// Echappatoire ?pagination=false : collection complete sans Paginator.
if (!$this->pagination->isEnabled($operation, $context)) {
return $qb->getQuery()->getResult();
}
// Branche paginee standard : offset/limit via Pagination, enveloppe dans
// le Paginator ORM (fetchJoinCollection: true pour compter correctement
// malgre les fetch-joins to-many sites/storageTypes du QueryBuilder).
$limit = $this->pagination->getLimit($operation, $context);
$page = max(1, $this->pagination->getPage($context));
$offset = ($page - 1) * $limit;
$qb->setFirstResult($offset)->setMaxResults($limit);
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
}
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
$id = $uriVariables['id'] ?? null;
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
return null;
}
$product = $this->repository->findById((int) $id);
if (null === $product) {
return null;
}
// § 2.7 : un produit soft-deleted n'est jamais expose (404).
if (null !== $product->getDeletedAt()) {
return null;
}
return $product;
}
/**
* Lit le filtre `?search=` (recherche partielle code + name). Renvoie la valeur
* trimmee ou null si absente / vide.
*/
private function readSearch(array $context): ?string
{
$raw = $context['filters']['search'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?categoryId=` (drawer « Filtrer »). Renvoie l'id entier ou null
* si absent / non numerique.
*/
private function readCategoryId(array $context): ?int
{
$raw = $context['filters']['categoryId'] ?? null;
if (is_int($raw)) {
return $raw;
}
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
}
/**
* Lit le filtre `?categoryCode=` (drawer « Filtrer »). Renvoie le code trimme ou
* null si absent / vide.
*/
private function readCategoryCode(array $context): ?string
{
$raw = $context['filters']['categoryCode'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?state=` (PURCHASE / SALE / OTHER). Normalise en majuscules et
* n'accepte qu'une valeur de l'enum borne ; toute autre valeur est ignoree (null).
*/
private function readState(array $context): ?string
{
$raw = $context['filters']['state'] ?? null;
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, self::VALID_STATES, true) ? $state : null;
}
/**
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
*
* @return list<int>
*/
private function readSiteIds(array $context): array
{
$raw = $context['filters']['siteId'] ?? null;
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$ids[] = (int) $value;
}
}
return array_values(array_unique($ids));
}
}
@@ -18,8 +18,10 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte * a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs * taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
* categorie porte un `code` stable. * ADRESSE porte les categories des blocs adresse (Siege, Contact issues,
* Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte
* un `code` stable.
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -78,6 +80,14 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Nettoyage' => 'NETTOYAGE', 'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT', 'Transport' => 'TRANSPORT',
], ],
'ADRESSE' => [
'Siège' => 'SIEGE',
'Contact issues' => 'CONTACT_ISSUES',
'Facturation' => 'FACTURATION',
'Livraison' => 'LIVRAISON',
'Approvisionnement' => 'APPROVISIONNEMENT',
'Méthaniseur' => 'METHANISEUR',
],
]; ];
public function __construct( public function __construct(
@@ -25,6 +25,10 @@ use Doctrine\Persistence\ObjectManager;
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage, * taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
* Transport). Mirroir de la migration Version20260612080000. * Transport). Mirroir de la migration Version20260612080000.
* *
* ADRESSE : ajout du type ADRESSE (code ADRESSE, label « Adresse »), taxonomie
* dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir
* de la migration Version20260625100000.
*
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque * entite managee par l ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
@@ -41,12 +45,14 @@ class CategoryTypeFixtures extends Fixture
/** /**
* Source unique des types : code technique => libelle FR. Doit rester aligne * Source unique des types : code technique => libelle FR. Doit rester aligne
* sur le seed des migrations Version20260602100000 (CLIENT), * sur le seed des migrations Version20260602100000 (CLIENT),
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE). * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et
* Version20260625100000 (ADRESSE).
*/ */
private const TYPES = [ private const TYPES = [
'CLIENT' => 'Client', 'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur', 'FOURNISSEUR' => 'Fournisseur',
'PRESTATAIRE' => 'Prestataire', 'PRESTATAIRE' => 'Prestataire',
'ADRESSE' => 'Adresse',
]; ];
public function __construct( public function __construct(
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Product;
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Product>
*/
class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
public function findById(int $id): ?Product
{
return $this->find($id);
}
public function save(Product $product): void
{
$this->getEntityManager()->persist($product);
$this->getEntityManager()->flush();
}
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('p')
->select('1')
->andWhere('p.code = :code')
->andWhere('p.deletedAt IS NULL')
->setParameter('code', $code)
->setMaxResults(1)
;
if (null !== $excludeId) {
$qb->andWhere('p.id != :excludeId')->setParameter('excludeId', $excludeId);
}
return [] !== $qb->getQuery()->getResult();
}
public function createListQueryBuilder(
bool $includeDeleted = false,
?string $search = null,
?int $categoryId = null,
?string $categoryCode = null,
?string $state = null,
array $siteIds = [],
): QueryBuilder {
// Eager-load des relations embarquees en liste (product:read) pour eviter
// un N+1 par produit : category (ManyToOne, sur), sites et storageTypes
// (ManyToMany BORNES — embed autorise, ne viole pas la regle n°13). Le
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
// compatible avec ces fetch-joins to-many (comptage par sous-requete d'ids).
$qb = $this->createQueryBuilder('p')
->leftJoin('p.category', 'cat')->addSelect('cat')
->leftJoin('p.sites', 's')->addSelect('s')
->leftJoin('p.storageTypes', 'stp')->addSelect('stp')
->orderBy('p.name', 'ASC')
;
// RG-6.09 : la liste exclut par defaut les produits soft-deleted.
if (!$includeDeleted) {
$qb->andWhere('p.deletedAt IS NULL');
}
// ?search= : recherche partielle case-insensitive sur code + name. Les
// metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. Les
// deux LIKE sont parenthese pour ne pas casser la precedence AND/OR avec
// les autres filtres (AND lie plus fort que OR en DQL).
if (null !== $search && '' !== trim($search)) {
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
$qb->andWhere('(LOWER(p.code) LIKE :search OR LOWER(p.name) LIKE :search)')
->setParameter('search', $pattern)
;
}
// ?categoryId= : filtre par categorie precise (id).
if (null !== $categoryId) {
$qb->andWhere('cat.id = :categoryId')->setParameter('categoryId', $categoryId);
}
// ?categoryCode= : filtre par categorie precise (code stable).
if (null !== $categoryCode && '' !== trim($categoryCode)) {
$qb->andWhere('cat.code = :categoryCode')->setParameter('categoryCode', trim($categoryCode));
}
// ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas
// exprimer la containment jsonb -> on resout les ids matchant en SQL natif
// (operateur @>), puis on contraint le QueryBuilder. Ids vides -> condition
// toujours fausse (aucun produit), sans casser le reste de la requete.
if (null !== $state) {
$stateIds = $this->matchingStateIds($state);
if ([] === $stateIds) {
$qb->andWhere('1 = 0');
} else {
$qb->andWhere('p.id IN (:stateIds)')->setParameter('stateIds', $stateIds);
}
}
// ?siteId[]= : produit disponible sur AU MOINS UN des sites passes (OR).
// Sous-requete EXISTS correlee pour ne PAS restreindre la collection sites
// eager-loadee `s` (sinon les autres sites du produit disparaitraient du
// JSON) et eviter les lignes dupliquees (cf. DoctrineCategoryRepository).
if ([] !== $siteIds) {
$sub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(Product::class, 'p_si')
->join('p_si.sites', 's_si')
->where('p_si = p')
->andWhere('s_si.id IN (:siteIds)')
;
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
->setParameter('siteIds', $siteIds)
;
}
return $qb;
}
/**
* Ids des produits dont la colonne JSONB `states` contient l'etat donne, via
* l'operateur de containment Postgres `@>`. L'etat est borne a l'enum
* {PURCHASE, SALE, OTHER} en amont (ProductProvider) pas de saisie libre ici.
*
* @return list<int>
*/
private function matchingStateIds(string $state): array
{
$rows = $this->getEntityManager()->getConnection()
->executeQuery(
'SELECT id FROM product WHERE states @> CAST(:state AS JSONB)',
['state' => (string) json_encode([$state])],
)
->fetchFirstColumn()
;
return array_map(static fn (mixed $id): int => (int) $id, $rows);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\StorageType;
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StorageType>
*/
class DoctrineStorageTypeRepository extends ServiceEntityRepository implements StorageTypeRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StorageType::class);
}
public function findById(int $id): ?StorageType
{
return $this->find($id);
}
/**
* @return list<StorageType>
*/
public function findAllOrderedByLabel(): array
{
return $this->findBy([], ['label' => 'ASC']);
}
}
@@ -42,7 +42,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* - sites : SiteInterface (module Sites) via resolve_target_entities * - sites : SiteInterface (module Sites) via resolve_target_entities
* - contacts : ClientContact (meme module) * - contacts : ClientContact (meme module)
* - categories : CategoryInterface (module Catalog) via resolve_target_entities * - categories : CategoryInterface (module Catalog) via resolve_target_entities
* codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78) * type ADRESSE attendu (validateCategoryType)
* *
* Audite (#[Auditable]) + Timestampable/Blamable. * Audite (#[Auditable]) + Timestampable/Blamable.
* *
@@ -96,11 +96,11 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/** /**
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre * Seules les categories PORTANT ce type sont autorisees sur une adresse client.
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse. * S'appuie sur CategoryInterface::getCategoryTypeCodes() (multi-type pas
* Toute autre categorie du type CLIENT est autorisee. * d'import du module Catalog, regle ABSOLUE n°1).
*/ */
private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']; private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -215,7 +215,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
private Collection $contacts; private Collection $contacts;
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse). // Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). // Categories de type ADRESSE uniquement (validateCategoryType).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinTable(name: 'client_address_category')]
@@ -335,20 +335,19 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
} }
/** /**
* RG-1.29 (ERP-78) : une adresse interdit les categories de code * Toute categorie posee sur une adresse client doit etre de type ADRESSE ->
* DISTRIBUTEUR / COURTIER elles decrivent une relation entre clients * sinon 422 avec violation sur le champ `categories`. S'appuie sur
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec * CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* violation sur le champ `categories`. Toute autre categorie (type unique * acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas * regle ABSOLUE n°1).
* d'import du module Catalog regle ABSOLUE n°1).
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryCodes(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
{ {
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) { && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé sur une adresse.') $context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
; ;
@@ -40,7 +40,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`. * un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`.
* - contacts : SupplierContact (meme module). * - contacts : SupplierContact (meme module).
* - categories : CategoryInterface (module Catalog) via resolve_target_entities * - categories : CategoryInterface (module Catalog) via resolve_target_entities
* type FOURNISSEUR attendu (RG-2.10, Assert\Callback validateCategoryType). * type ADRESSE attendu (Assert\Callback validateCategoryType).
* *
* Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read, * Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read,
* maillon (a)). * maillon (a)).
@@ -110,11 +110,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU']; public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
/** /**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une * Seules les categories PORTANT ce type sont autorisees sur une adresse
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() * fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas
* (pas d'import du module Catalog regle ABSOLUE n°1). * d'import du module Catalog regle ABSOLUE n°1).
*/ */
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -208,8 +208,8 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[Groups(['supplier:item:read', 'supplier:write:addresses'])] #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $contacts; private Collection $contacts;
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est // Au moins une categorie de type ADRESSE par adresse (le type est controle par
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites). // validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'supplier_address_category')] #[ORM\JoinTable(name: 'supplier_address_category')]
@@ -227,12 +227,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
} }
/** /**
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de * Toute categorie posee sur une adresse fournisseur doit etre de type ADRESSE
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * -> sinon 422 avec violation sur le champ `categories` (propertyPath aligne
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur * ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est * CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform. * regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
@@ -240,7 +240,7 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') $context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
; ;
@@ -55,8 +55,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent * restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail * les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail
* ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse * ssi facturation, categories de type ADRESSE sur les adresses).
* RG-1.29, ERP-78).
* *
* Depend de CategoryFixtures (categories), SitesFixtures (sites) et * Depend de CategoryFixtures (categories), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables Bank / PaymentType). * CommercialReferentialFixtures (referentiels comptables Bank / PaymentType).
@@ -116,7 +115,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
); );
if ($gsoIsNew) { if ($gsoIsNew) {
$this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr'); $this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr');
$this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Transport/Logistique']); $this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Livraison']);
} }
// Courtier reference par d'autres clients. // Courtier reference par d'autres clients.
@@ -140,7 +139,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$dubois->setPaymentType($this->paymentType($manager, 'VIREMENT')); $dubois->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$dubois->setBank($this->bank($manager, 'SG')); $dubois->setBank($this->bank($manager, 'SG'));
$this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr'); $this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr');
$this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['BTP']); $this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['Livraison']);
} }
// === Dependant d'un distributeur (RG-1.03) === // === Dependant d'un distributeur (RG-1.03) ===
@@ -176,7 +175,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
if ($isNew) { if ($isNew) {
$transports->setPaymentType($this->paymentType($manager, 'LCR')); $transports->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr'); $this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr');
$this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Transport/Logistique']); $this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Approvisionnement']);
$this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); $this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
$this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1); $this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1);
} }
@@ -192,9 +191,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
// Prospect : exclusif de livraison/facturation (sans billingEmail). // Prospect : exclusif de livraison/facturation (sans billingEmail).
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0); $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0);
// Livraison. // Livraison.
$this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Industrie'], position: 1); $this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Livraison'], position: 1);
// Facturation : billingEmail obligatoire. // Facturation : billingEmail obligatoire.
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', position: 2); $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', categoryNames: ['Facturation'], position: 2);
} }
// === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) === // === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ===
@@ -249,7 +248,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$holding->setDirectorName('Antoine Lefèvre'); $holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1250000.00'); $holding->setProfitAmount('1250000.00');
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr'); $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr');
$this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Industrie']); $this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Siège']);
} }
// === Multi-categories M2M === // === Multi-categories M2M ===
@@ -260,7 +259,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
); );
if ($isNew) { if ($isNew) {
$this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr'); $this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr');
$this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['BTP', 'Services']); $this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['Livraison', 'Approvisionnement']);
} }
// === Prospect seul === // === Prospect seul ===
@@ -282,7 +281,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
); );
if ($isNew) { if ($isNew) {
$this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr'); $this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr');
$this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Association']); $this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Contact issues']);
} }
$manager->flush(); $manager->flush();
@@ -359,10 +358,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
/** /**
* Ajoute une adresse au client (cascade persist via Client.addresses). Les * Ajoute une adresse au client (cascade persist via Client.addresses). Les
* donnees respectent les validators : exclusivite Prospect, billingEmail ssi * donnees respectent les validators : exclusivite Prospect, billingEmail ssi
* facturation, aucune categorie de code DISTRIBUTEUR/COURTIER (RG-1.29). * facturation, categories de type ADRESSE uniquement.
* *
* @param list<string> $siteNames au moins un site (RG-1.10) * @param list<string> $siteNames au moins un site (RG-1.10)
* @param list<string> $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29) * @param list<string> $categoryNames categories de type ADRESSE (Siege, Livraison...)
*/ */
private function addAddress( private function addAddress(
Client $client, Client $client,
@@ -186,6 +186,10 @@ final class SeedE2ECommand extends Command
'sites.bypass_scope', 'sites.bypass_scope',
'catalog.categories.view', 'catalog.categories.view',
'catalog.categories.manage', 'catalog.categories.manage',
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx
// p.3) : mappe sur le persona "tout". Miroir de personas.ts.
'catalog.products.view',
'catalog.products.manage',
// Commercial — Repertoire clients (M1). Mappe ici sur le // Commercial — Repertoire clients (M1). Mappe ici sur le
// persona "tout" en attendant les vrais roles metier // persona "tout" en attendant les vrais roles metier
// (bureau/compta/commerciale/usine) seedes par ERP-74. // (bureau/compta/commerciale/usine) seedes par ERP-74.
@@ -28,9 +28,7 @@ interface CategoryInterface
* entre environnements) ni importer la classe concrete Category (regle * entre environnements) ni importer la classe concrete Category (regle
* ABSOLUE n°1). Pilote, cote M1 Commercial : * ABSOLUE n°1). Pilote, cote M1 Commercial :
* - RG-1.03 : un distributor doit referencer un client portant la categorie * - RG-1.03 : un distributor doit referencer un client portant la categorie
* de code DISTRIBUTEUR (resp. COURTIER pour broker) ; * de code DISTRIBUTEUR (resp. COURTIER pour broker).
* - RG-1.29 : une adresse interdit les categories de code DISTRIBUTEUR /
* COURTIER (relations entre clients, pas des attributs d'adresse).
*/ */
public function getCode(): ?string; public function getCode(): ?string;
@@ -38,9 +36,10 @@ interface CategoryInterface
* Codes des types de categorie rattaches (CategoryType::code), tableau vide * Codes des types de categorie rattaches (CategoryType::code), tableau vide
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter * si aucun. Depuis le passage en ManyToMany, une categorie peut porter
* plusieurs types : un module tiers teste l'appartenance via * plusieurs types : un module tiers teste l'appartenance via
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote * `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote la
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type * RG-2.10 (une categorie de fournisseur doit etre de type FOURNISSEUR) et la
* FOURNISSEUR). * validation des blocs adresse (categories de type ADRESSE uniquement, client
* comme fournisseur).
* *
* @return list<string> * @return list<string>
*/ */
@@ -271,9 +271,9 @@ final class ColumnCommentsCatalog
], ],
'client_address_category' => [ 'client_address_category' => [
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).', '_table' => 'Jointure M2M client_address <-> category — categories d adresse de type ADRESSE uniquement.',
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).', 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type ADRESSE.',
], ],
'client_rib' => [ 'client_rib' => [
@@ -360,9 +360,9 @@ final class ColumnCommentsCatalog
], ],
'supplier_address_category' => [ 'supplier_address_category' => [
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).', '_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type ADRESSE.',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.', 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).', 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type ADRESSE.',
], ],
'supplier_rib' => [ 'supplier_rib' => [
@@ -575,6 +575,47 @@ final class ColumnCommentsCatalog
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.', 'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.', 'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
// M6 Catalog (ERP-199) — tables desormais mappees par les entites
// Product / StorageType : schema:update (test) les recree sans COMMENT
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
// identiques aux COMMENT de la migration Version20260625110000 (ERP-198).
'storage_type' => [
'_table' => 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).',
'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).',
],
'storage_type_site' => [
'_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.',
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.',
],
'product' => [
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).',
'name' => 'Nom du produit (≤ 255). Normalise serveur (trim).',
'states' => 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.',
'manufactured' => '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).',
'contains_molasses' => '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).',
'category_id' => 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).',
'deleted_at' => 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.',
] + self::timestampableBlamableComments(),
'product_site' => [
'_table' => 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).',
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.',
],
'product_storage_type' => [
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).',
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
],
]; ];
} }
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Architecture; namespace App\Tests\Architecture;
use App\Module\Catalog\Domain\Entity\CategoryType; use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Catalog\Domain\Entity\StorageType;
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\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentDelay;
@@ -55,6 +56,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
* - CategoryType : referentiel statique (codes de typage des categories), * - CategoryType : referentiel statique (codes de typage des categories),
* pas de besoin de tracabilite user-driven (cree par migration/seed, * pas de besoin de tracabilite user-driven (cree par migration/seed,
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17. * pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
* - StorageType (M6, ERP-199) : referentiel PROVISOIRE des types de stockage
* (en attente liste Aurore HP-M6-02), cree par migration + seede (ERP-201),
* lecture seule au M6. Pas de tracabilite user-driven, meme justification que
* CategoryType. Cf. spec-back M6 § 2.4 + § 2.6.
* - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels * - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
* comptables statiques (id/code/label/position), seedes par migration + * comptables statiques (id/code/label/position), seedes par migration +
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de * CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
Permission::class, Permission::class,
Site::class, Site::class,
CategoryType::class, CategoryType::class,
StorageType::class,
TvaMode::class, TvaMode::class,
PaymentDelay::class, PaymentDelay::class,
PaymentType::class, PaymentType::class,
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\CategoryType;
/**
* Tests du seed de la taxonomie ADRESSE cote API.
*
* Le multi-select « Categorie » des blocs adresse (client + fournisseur) consomme
* `GET /api/categories?typeCode=ADRESSE`. Ce test prouve que :
* - le filtre `?typeCode=ADRESSE` ne renvoie QUE les categories du type ADRESSE
* (aucune fuite de categorie d'un autre type) ;
* - chaque membre renvoye porte bien le type ADRESSE dans `categoryTypes`.
*
* NB : la base de test est purgee de toute categorie / type entre chaque test
* (cf. AbstractCatalogApiTestCase::cleanupCatalogTestData), donc le type et les
* categories ADRESSE sont materialises ici (et non lus depuis le seed de la
* migration / fixture, qui ne survit pas a la purge). On valide ainsi le contrat
* du filtre sur le code reel `ADRESSE`. La presence du seed apres un
* `make db-reset` reel est, elle, verifiee par l'idempotence des fixtures.
*
* @internal
*/
final class CategoryAdresseSeedTest extends AbstractCatalogApiTestCase
{
/**
* Categories de demonstration seedees par la migration / fixture ADRESSE.
*/
private const array ADDRESS_CATEGORIES = [
'Siège',
'Contact issues',
'Facturation',
'Livraison',
'Approvisionnement',
'Méthaniseur',
];
public function testTypeCodeAdresseReturnsOnlyAddressCategories(): void
{
$addressType = $this->getOrCreateAdresseType();
foreach (self::ADDRESS_CATEGORIES as $name) {
$this->createCategory($name, $addressType);
}
// Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
$noiseType = $this->createCategoryType('TEST_CLIENT', 'Test Client');
$this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=ADRESSE&pagination=false');
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
$names = array_map(static fn (array $m): string => $m['name'], $members);
sort($names);
$expected = self::ADDRESS_CATEGORIES;
sort($expected);
self::assertSame(
$expected,
$names,
'Le filtre ?typeCode=ADRESSE doit ne renvoyer QUE les categories du type ADRESSE.',
);
// Chaque categorie remontee doit PORTER le type ADRESSE.
foreach ($members as $member) {
self::assertContains('ADRESSE', array_column($member['categoryTypes'], 'code'));
}
}
public function testTypeCodeAdresseKeepsHydraPagination(): void
{
$addressType = $this->getOrCreateAdresseType();
$this->createCategory('Siège', $addressType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=ADRESSE');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) {
self::assertContains('ADRESSE', array_column($member['categoryTypes'], 'code'));
}
}
/**
* Recupere le type ADRESSE reel, ou le cree s'il est absent. Le code `ADRESSE`
* est seede par CategoryTypeFixtures (present en debut de suite), mais le
* cleanup purge tous les `category_type` entre les tests : selon l'ordre
* d'execution, le type peut donc exister ou non. Le get-or-create rend le test
* robuste sans dependre du seed ni le dupliquer.
*/
private function getOrCreateAdresseType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'ADRESSE']);
if ($existing instanceof CategoryType) {
return $existing;
}
return $this->createCategoryType('ADRESSE', 'Adresse');
}
}
@@ -36,10 +36,10 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_'; protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
/** /**
* Codes pilotant les RG (RG-1.03 distributor/broker, RG-1.29 adresse) : ils * Codes pilotant les RG (RG-1.03 distributor/broker) : ils doivent matcher
* doivent matcher exactement, donc createCategory() les fetch-or-create par * exactement, donc createCategory() les fetch-or-create par code. Les autres
* code. Les autres codes sont traites comme de simples libelles generiques et * codes sont traites comme de simples libelles generiques et produisent une
* produisent une categorie a code UNIQUE (cf. createCategory). * categorie a code UNIQUE (cf. createCategory).
*/ */
private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER']; private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER'];
@@ -75,6 +75,47 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
return $type; return $type;
} }
/**
* Recupere (ou cree) le type ADRESSE (categories des blocs adresse). Idempotent
* via l'unicite de category_type.code. Laisse en place au tearDown.
*/
protected function addressCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'ADRESSE']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('ADRESSE');
$type->setLabel('Adresse');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Cree une Category de test de type ADRESSE (autorisee sur un bloc adresse).
* Code UNIQUE (suffixe aleatoire) : les categories d'adresse ne pilotent aucune
* RG par code, deux appels produisent donc deux categories distinctes.
*/
protected function createAddressCategory(): Category
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'adresse_'.$suffix);
$category->setCode('ADRESSE_'.strtoupper($suffix));
$category->addCategoryType($this->addressCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/** /**
* Cree une Category de test sous le type unique CLIENT (ERP-78). * Cree une Category de test sous le type unique CLIENT (ERP-78).
* *
@@ -134,8 +134,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
* Seede un fournisseur COMPLET (sans passer par l'API validations * Seede un fournisseur COMPLET (sans passer par l'API validations
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information * applicatives non rejouees mais CHECK BDD respectes) : onglet Information
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse * rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie * multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie de type
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle * ADRESSE, >= 1 contact, >= 1 categorie FOURNISSEUR sur le fournisseur. Sert de socle
* au contrat de serialisation et a la DoD (§ 4.0.bis). * au contrat de serialisation et a la DoD (§ 4.0.bis).
* *
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR, * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
@@ -202,7 +202,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
foreach ($sites as $site) { foreach ($sites as $site) {
$address->addSite($site); $address->addSite($site);
} }
$address->addCategory($this->supplierCategory('NEGOCIANT')); // Categorie de bloc adresse : type ADRESSE (et non FOURNISSEUR — celui-ci
// reste sur le bloc principal du fournisseur).
$address->addCategory($this->createAddressCategory());
$address->addContact($contact); $address->addContact($contact);
$supplier->addAddress($address); $supplier->addAddress($address);
$em->persist($address); $em->persist($address);
@@ -15,8 +15,8 @@ use App\Module\Sites\Domain\Entity\Site;
* - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs * - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs
* is_delivery / is_billing ; * is_delivery / is_billing ;
* - RG-1.11 : billing_email obligatoire ssi is_billing ; * - RG-1.11 : billing_email obligatoire ssi is_billing ;
* - RG-1.29 (ERP-78) : les categories de code DISTRIBUTEUR / COURTIER sont * - categorie d'adresse : seules les categories de type ADRESSE sont acceptees
* interdites sur une adresse (-> 422) ; toute autre categorie est acceptee. * (-> 422 sinon), au moins une est obligatoire.
* *
* Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite * Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite
* ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide * ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide
@@ -170,7 +170,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email'); $seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -197,7 +197,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Billing Two Emails'); $seed = $this->seedClient('Billing Two Emails');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -225,7 +225,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Secondary Email Non Billing'); $seed = $this->seedClient('Secondary Email Non Billing');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -246,15 +246,16 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
} }
/** /**
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 * Une categorie qui n'est PAS de type ADRESSE (ici une categorie CLIENT) est
* avec violation sur le champ `categories`. * refusee sur une adresse -> 422 avec violation sur le champ `categories`.
*/ */
public function testAddressRejectsDistributorCategory(): void public function testAddressRejectsNonAddressCategory(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Cat'); $seed = $this->seedClient('Address Non Address Cat');
$category = $this->createCategory('DISTRIBUTEUR'); // Categorie de type CLIENT (et non ADRESSE) -> doit etre refusee sur l'adresse.
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -270,70 +271,20 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertStringContainsString( self::assertStringContainsString(
'Type de catégorie non autorisé sur une adresse.', 'Type de catégorie non autorisé (ADRESSE attendu).',
(string) $client->getResponse()->getContent(false), (string) $client->getResponse()->getContent(false),
); );
} }
/** /**
* RG-1.29 : poster une categorie de type COURTIER sur une adresse -> 422. * Une categorie de type ADRESSE est acceptee sur une adresse -> 201.
*/ */
public function testAddressRejectsBrokerCategory(): void public function testAddressAcceptsAddressCategory(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Cat'); $seed = $this->seedClient('Address Address Cat');
$category = $this->createCategory('COURTIER'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* RG-1.29 : une categorie de type SECTEUR est autorisee sur une adresse.
*/
public function testAddressAcceptsSectorCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Sector Cat');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-1.29 : une categorie de type AUTRE est autorisee sur une adresse.
*/
public function testAddressAcceptsOtherCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Other Cat');
$category = $this->createCategory('AUTRE');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -385,7 +336,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type'); $seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -413,7 +364,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Type'); $seed = $this->seedClient('Address Broker Type');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -435,7 +386,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Type'); $seed = $this->seedClient('Address Distributor Type');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -462,7 +413,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Mix'); $seed = $this->seedClient('Address Broker Mix');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -203,7 +203,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Host'); $seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -276,7 +276,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Addr Multi'); $seed = $this->seedClient('Addr Multi');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$this->seedAddress($seed, 'Bordeaux'); $this->seedAddress($seed, 'Bordeaux');
$this->seedAddress($seed, 'Lyon'); $this->seedAddress($seed, 'Lyon');
@@ -305,7 +305,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/999999/addresses', [ $client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -106,7 +106,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Host'); $seed = $this->seedSupplier('Address Host');
$category = $this->supplierCategory('NEGOCIANT'); $category = $this->createAddressCategory();
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -174,7 +174,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Incoherent'); $seed = $this->seedSupplier('Address Incoherent');
$category = $this->supplierCategory('NEGOCIANT'); $category = $this->createAddressCategory();
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur. // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -222,7 +222,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types'); $seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->supplierCategory('NEGOCIANT'); $category = $this->createAddressCategory();
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) { foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -240,12 +240,12 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
} }
} }
public function testPostAddressWithNonFournisseurCategoryReturns422(): void public function testPostAddressWithNonAddressCategoryReturns422(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad Cat'); $seed = $this->seedSupplier('Address Bad Cat');
// categorie de type CLIENT -> interdite sur une adresse fournisseur. // categorie de type CLIENT (et non ADRESSE) -> interdite sur une adresse.
$clientTypedCategory = $this->createCategory('SECTEUR'); $clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -260,7 +260,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
], ],
]); ]);
// RG-2.10 -> 422 rattachee a categories. // Categorie hors type ADRESSE -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
} }