Compare commits

..

8 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
45 changed files with 2171 additions and 349 deletions
+10
View File
@@ -134,6 +134,16 @@ return [
'module' => 'transport',
'permission' => 'transport.carriers.view',
],
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
// `catalog.products.view` et son module owner `catalog`.
[
'label' => 'sidebar.catalog.products',
'to' => '/admin/products',
'icon' => 'mdi:package-variant-closed',
'module' => 'catalog',
'permission' => 'catalog.products.view',
],
[
'label' => 'sidebar.core.roles',
'to' => '/admin/roles',
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.149'
app.version: '0.1.151'
+4 -1
View File
@@ -52,7 +52,8 @@
"admin": "Sites"
},
"catalog": {
"categories": "Gestion des catégories"
"categories": "Gestion des catégories",
"products": "Catalogue produit"
}
},
"dashboard": {
@@ -663,6 +664,7 @@
"confirm": "Supprimer"
},
"price": {
"title": "Prix {n}",
"direction": "Sens",
"directionClient": "Client",
"directionSupplier": "Fournisseur",
@@ -815,6 +817,7 @@
"core_permission": "Permission",
"sites_site": "Site",
"catalog_category": "Catégorie",
"catalog_product": "Produit",
"commercial_client": "Client",
"commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client",
@@ -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).
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 () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') {
@@ -68,6 +68,9 @@ export function useClientReferentials() {
const api = useApi()
const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// CLIENT du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
@@ -109,6 +112,9 @@ export function useClientReferentials() {
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja
@@ -151,6 +157,7 @@ export function useClientReferentials() {
return {
categories,
addressCategories,
sites,
tvaModes,
paymentDelays,
@@ -62,6 +62,9 @@ export function useSupplierReferentials() {
const api = useApi()
const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// FOURNISSEUR du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([])
@@ -97,6 +100,9 @@ export function useSupplierReferentials() {
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ».
@@ -121,6 +127,7 @@ export function useSupplierReferentials() {
return {
categories,
addressCategories,
sites,
tvaModes,
paymentDelays,
@@ -93,7 +93,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
@@ -479,9 +479,6 @@ import { readHistoryTab } from '~/shared/utils/historyTab'
const SIREN_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 api = useApi()
const toast = useToast()
@@ -573,15 +570,17 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))]
}
const embedCategoryOptions = computed<CategoryOption[]>(() => {
const fromClient = categoryOptionsOf(client.value?.categories)
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
return mergeOptions(fromClient, fromAddresses)
})
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
// Categories du formulaire principal (type CLIENT) : referentiel UNION categories
// embarquees du client (fallback si le referentiel n'est pas chargeable).
const embedClientCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(client.value?.categories))
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedClientCategoryOptions.value))
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
// embarquees des adresses (fallback meme fonction qu'au-dessus).
const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
)
const addressCategoryOptions = computed(() =>
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
)
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]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
@@ -87,7 +87,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) sur les inputs, dont
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
@@ -456,9 +456,6 @@ const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
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 api = useApi()
const toast = useToast()
@@ -816,10 +813,8 @@ async function submitContacts(): Promise<void> {
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false)
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
const addressCategoryOptions = computed(() =>
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
)
// Categories autorisees sur une adresse : taxonomie dediee type ADRESSE.
const addressCategoryOptions = computed(() => referentials.addressCategories.value)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
const contactOptions = computed<RefOption[]>(() =>
@@ -56,7 +56,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<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. -->
<MalioInputTextArea
v-model="information.description"
@@ -181,7 +181,7 @@
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="mainCategoryOptions"
:category-options="addressCategoryOptions"
:site-options="siteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
@@ -536,15 +536,18 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))]
}
// Categories issues de l'embed (fournisseur + adresses), role-independantes.
const embedCategoryOptions = computed<CategoryOption[]>(() => {
const fromSupplier = categoryOptionsOf(supplier.value?.categories)
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))
return mergeOptions(fromSupplier, fromAddresses)
})
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10).
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value))
// Categories du formulaire principal (type FOURNISSEUR) : referentiel UNION
// categories embarquees du fournisseur (fallback si referentiel non chargeable).
const embedSupplierCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(supplier.value?.categories))
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedSupplierCategoryOptions.value))
// Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
// embarquees des adresses (meme logique de fallback).
const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
)
const addressCategoryOptions = computed(() =>
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
)
const embedSiteOptions = computed<RefOption[]>(() =>
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]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea
@@ -51,7 +51,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="mt-12 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputTextArea
v-model="information.description"
:label="t('commercial.suppliers.form.information.description')"
@@ -179,7 +179,7 @@
:model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1"
:category-options="referentials.categories.value"
:category-options="referentials.addressCategories.value"
:site-options="referentials.sites.value"
:contact-options="contactOptions"
:country-options="countryOptions"
@@ -1,190 +1,199 @@
<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation côté parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
@click="$emit('remove')"
/>
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
case « Affréter ». Pas de label de groupe. -->
<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>
<!-- Bloc a plat (sans box-shadow) : un filet noir 1px le separe du suivant
(pas de bordure sous le dernier bloc), aligne sur les blocs contact / adresse. -->
<div class="pb-[20px]" :class="{ 'border-b border-black': !last }">
<!-- En-tete : titre du bloc (noir) a gauche, poubelle de suppression a droite. -->
<div class="flex items-center justify-between">
<h2 class="text-[20px] font-semibold text-black">{{ title }}</h2>
<!-- Suppression : modal de confirmation côté parent. -->
<MalioButtonIcon
v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
v-bind="{ ariaLabel: t('transport.carriers.form.price.remove') }"
@click="$emit('remove')"
/>
</div>
<!-- Branche CLIENT (RG-4.10). -->
<template v-if="model.direction === 'CLIENT'">
<MalioSelect
:model-value="model.clientIri"
:options="clientOptions"
: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). -->
<!-- Grille 4 colonnes des champs du prix. -->
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<!-- RG-4.09 : sens du prix (CLIENT / FOURNISSEUR) en colonne 1 / ligne 1, radios
EN LIGNE (horizontaux), centrés sur la hauteur de champ (h-12) comme la
case « Affréter ». Pas de label de groupe. -->
<div>
<div class="flex h-12 items-center gap-4">
<div class="flex h-12 items-center gap-6">
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
: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="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
@update:model-value="onDirectionChange"
/>
<MalioRadioButton
:model-value="model.containerType"
:name="`price-container-${uid}`"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
: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="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
@update:model-value="onDirectionChange"
/>
</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>
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.pricingUnit"
:name="`price-unit-${uid}`"
value="FORFAIT"
: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))"
/>
<!-- Branche CLIENT (RG-4.10). -->
<template v-if="model.direction === 'CLIENT'">
<MalioSelect
:model-value="model.clientIri"
:options="clientOptions"
: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 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>
<p v-if="errors?.pricingUnit" class="ml-[2px] text-xs text-m-danger">{{ errors.pricingUnit }}</p>
</div>
<MalioInputAmount
:model-value="model.price"
:label="t('transport.carriers.form.price.price')"
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.price"
@update:model-value="(v: string) => update('price', v)"
/>
<!-- Tarification : Forfait / Tonne (radios centrés h-12, pas de label). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.pricingUnit"
:name="`price-unit-${uid}`"
value="FORFAIT"
: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
: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>
<MalioInputAmount
:model-value="model.price"
:label="t('transport.carriers.form.price.price')"
:required="true"
:readonly="readonly"
:disabled="disabled"
:error="errors?.price"
@update:model-value="(v: string) => update('price', v)"
/>
<MalioSelect
: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>
</template>
@@ -200,6 +209,10 @@ interface SelectOption {
const props = defineProps<{
/** Brouillon du prix (v-model). */
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). */
clientOptions: SelectOption[]
/** Fournisseurs disponibles (IRI en value). */
@@ -182,10 +182,12 @@
v-for="(price, index) in prices"
:key="index"
:model-value="price"
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
:client-options="clientOptions"
:supplier-options="supplierOptions"
:site-options="siteOptions"
removable
:last="index === prices.length - 1"
:errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)"
@@ -244,11 +244,13 @@
v-for="(price, index) in prices"
:key="index"
:model-value="price"
:title="t('transport.carriers.form.price.title', { n: index + 1 })"
:client-options="clientOptions"
:supplier-options="supplierOptions"
:site-options="siteOptions"
:removable="!isValidated('prices')"
:disabled="isValidated('prices')"
:last="index === prices.length - 1"
:errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)"
+10 -4
View File
@@ -35,7 +35,7 @@ export interface Persona {
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
// 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'
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
password: SHARED_PASSWORD,
isAdmin: true,
permissions: [],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
},
'user-full': {
key: 'user-full',
@@ -65,6 +65,12 @@ export const personas: Record<PersonaKey, Persona> = {
'sites.bypass_scope',
'catalog.categories.view',
'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
// "tout" en attendant les vrais roles metier (bureau/compta/
// 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.manage',
],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
},
'user-readonly': {
key: 'user-readonly',
@@ -155,4 +161,4 @@ export function getPersona(key: PersonaKey): Persona {
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_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_product_code_active ON product (code) WHERE deleted_at IS NULL"
fixtures:
$(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
// 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)'],
// 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
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque
* categorie porte un `code` stable.
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
* 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
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -78,6 +80,14 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT',
],
'ADRESSE' => [
'Siège' => 'SIEGE',
'Contact issues' => 'CONTACT_ISSUES',
'Facturation' => 'FACTURATION',
'Livraison' => 'LIVRAISON',
'Approvisionnement' => 'APPROVISIONNEMENT',
'Méthaniseur' => 'METHANISEUR',
],
];
public function __construct(
@@ -25,6 +25,10 @@ use Doctrine\Persistence\ObjectManager;
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
* 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
* 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
@@ -41,12 +45,14 @@ class CategoryTypeFixtures extends Fixture
/**
* Source unique des types : code technique => libelle FR. Doit rester aligne
* sur le seed des migrations Version20260602100000 (CLIENT),
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE).
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et
* Version20260625100000 (ADRESSE).
*/
private const TYPES = [
'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur',
'PRESTATAIRE' => 'Prestataire',
'ADRESSE' => 'Adresse',
];
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
* - contacts : ClientContact (meme module)
* - 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.
*
@@ -96,11 +96,11 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
use TimestampableBlamableTrait;
/**
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse.
* Toute autre categorie du type CLIENT est autorisee.
* Seules les categories PORTANT ce type sont autorisees sur une adresse client.
* S'appuie sur CategoryInterface::getCategoryTypeCodes() (multi-type pas
* 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\GeneratedValue]
@@ -215,7 +215,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
private Collection $contacts;
// 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> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[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
* DISTRIBUTEUR / COURTIER elles decrivent une relation entre clients
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec
* violation sur le champ `categories`. Toute autre categorie (type unique
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas
* d'import du module Catalog regle ABSOLUE n°1).
* Toute categorie posee sur une adresse client doit etre de type ADRESSE ->
* sinon 422 avec violation sur le champ `categories`. S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
* regle ABSOLUE n°1).
*/
#[Assert\Callback]
public function validateCategoryCodes(ExecutionContextInterface $context): void
public function validateCategoryType(ExecutionContextInterface $context): void
{
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) {
$context->buildViolation('Type de catégorie non autorisé sur une adresse.')
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
->atPath('categories')
->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`.
* - contacts : SupplierContact (meme module).
* - 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,
* maillon (a)).
@@ -110,11 +110,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
/**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes()
* (pas d'import du module Catalog regle ABSOLUE n°1).
* Seules les categories PORTANT ce type sont autorisees sur une adresse
* fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas
* 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\GeneratedValue]
@@ -208,8 +208,8 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $contacts;
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
// Au moins une categorie de type ADRESSE par adresse (le type est controle par
// validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[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
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
* Toute categorie posee sur une adresse fournisseur doit etre de type ADRESSE
* -> sinon 422 avec violation sur le champ `categories` (propertyPath aligne
* ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type la categorie est
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
* acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/
#[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void
@@ -240,7 +240,7 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface
&& !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')
->addViolation()
;
@@ -55,8 +55,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail
* ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse
* RG-1.29, ERP-78).
* ssi facturation, categories de type ADRESSE sur les adresses).
*
* Depend de CategoryFixtures (categories), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables Bank / PaymentType).
@@ -116,7 +115,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
);
if ($gsoIsNew) {
$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.
@@ -140,7 +139,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$dubois->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$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->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) ===
@@ -176,7 +175,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
if ($isNew) {
$transports->setPaymentType($this->paymentType($manager, 'LCR'));
$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 secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1);
}
@@ -192,9 +191,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
// Prospect : exclusif de livraison/facturation (sans billingEmail).
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0);
// 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.
$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) ===
@@ -249,7 +248,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1250000.00');
$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 ===
@@ -260,7 +259,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
);
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->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 ===
@@ -282,7 +281,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
);
if ($isNew) {
$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();
@@ -359,10 +358,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
/**
* Ajoute une adresse au client (cascade persist via Client.addresses). Les
* 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> $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29)
* @param list<string> $categoryNames categories de type ADRESSE (Siege, Livraison...)
*/
private function addAddress(
Client $client,
@@ -186,6 +186,10 @@ final class SeedE2ECommand extends Command
'sites.bypass_scope',
'catalog.categories.view',
'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
// persona "tout" en attendant les vrais roles metier
// (bureau/compta/commerciale/usine) seedes par ERP-74.
@@ -28,9 +28,7 @@ interface CategoryInterface
* entre environnements) ni importer la classe concrete Category (regle
* ABSOLUE n°1). Pilote, cote M1 Commercial :
* - RG-1.03 : un distributor doit referencer un client portant la categorie
* 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).
* de code DISTRIBUTEUR (resp. COURTIER pour broker).
*/
public function getCode(): ?string;
@@ -38,9 +36,10 @@ interface CategoryInterface
* Codes des types de categorie rattaches (CategoryType::code), tableau vide
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter
* plusieurs types : un module tiers teste l'appartenance via
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type
* FOURNISSEUR).
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote la
* RG-2.10 (une categorie de fournisseur doit etre de type FOURNISSEUR) et la
* validation des blocs adresse (categories de type ADRESSE uniquement, client
* comme fournisseur).
*
* @return list<string>
*/
@@ -271,9 +271,9 @@ final class ColumnCommentsCatalog
],
'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.',
'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' => [
@@ -360,9 +360,9 @@ final class ColumnCommentsCatalog
],
'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.',
'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' => [
@@ -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.',
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
] + 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;
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\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
@@ -55,6 +56,10 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
* - CategoryType : referentiel statique (codes de typage des categories),
* 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.
* - 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
* comptables statiques (id/code/label/position), seedes par migration +
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
Permission::class,
Site::class,
CategoryType::class,
StorageType::class,
TvaMode::class,
PaymentDelay::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_';
/**
* Codes pilotant les RG (RG-1.03 distributor/broker, RG-1.29 adresse) : ils
* doivent matcher exactement, donc createCategory() les fetch-or-create par
* code. Les autres codes sont traites comme de simples libelles generiques et
* produisent une categorie a code UNIQUE (cf. createCategory).
* Codes pilotant les RG (RG-1.03 distributor/broker) : ils doivent matcher
* exactement, donc createCategory() les fetch-or-create par code. Les autres
* codes sont traites comme de simples libelles generiques et produisent une
* categorie a code UNIQUE (cf. createCategory).
*/
private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER'];
@@ -75,6 +75,47 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
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).
*
@@ -134,8 +134,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
* Seede un fournisseur COMPLET (sans passer par l'API validations
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie de type
* ADRESSE, >= 1 contact, >= 1 categorie FOURNISSEUR sur le fournisseur. Sert de socle
* au contrat de serialisation et a la DoD (§ 4.0.bis).
*
* @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) {
$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);
$supplier->addAddress($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
* is_delivery / is_billing ;
* - RG-1.11 : billing_email obligatoire ssi is_billing ;
* - RG-1.29 (ERP-78) : les categories de code DISTRIBUTEUR / COURTIER sont
* interdites sur une adresse (-> 422) ; toute autre categorie est acceptee.
* - categorie d'adresse : seules les categories de type ADRESSE sont acceptees
* (-> 422 sinon), au moins une est obligatoire.
*
* Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite
* ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide
@@ -170,7 +170,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -197,7 +197,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Billing Two Emails');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -225,7 +225,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Secondary Email Non Billing');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'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
* avec violation sur le champ `categories`.
* Une categorie qui n'est PAS de type ADRESSE (ici une categorie CLIENT) est
* refusee sur une adresse -> 422 avec violation sur le champ `categories`.
*/
public function testAddressRejectsDistributorCategory(): void
public function testAddressRejectsNonAddressCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Cat');
$category = $this->createCategory('DISTRIBUTEUR');
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Non Address Cat');
// 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', [
'headers' => ['Content-Type' => self::LD],
@@ -270,70 +271,20 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422);
self::assertStringContainsString(
'Type de catégorie non autorisé sur une adresse.',
'Type de catégorie non autorisé (ADRESSE attendu).',
(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();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Cat');
$category = $this->createCategory('COURTIER');
$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');
$seed = $this->seedClient('Address Address Cat');
$category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -385,7 +336,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -413,7 +364,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Type');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -435,7 +386,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Type');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -462,7 +413,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Mix');
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -203,7 +203,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -276,7 +276,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient();
$seed = $this->seedClient('Addr Multi');
$siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$this->seedAddress($seed, 'Bordeaux');
$this->seedAddress($seed, 'Lyon');
@@ -305,7 +305,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR');
$category = $this->createAddressCategory();
$client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -106,7 +106,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Host');
$category = $this->supplierCategory('NEGOCIANT');
$category = $this->createAddressCategory();
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
@@ -174,7 +174,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$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.
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -222,7 +222,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri();
$category = $this->supplierCategory('NEGOCIANT');
$category = $this->createAddressCategory();
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
$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();
$client = $this->createAdminClient();
$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');
$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::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}