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>
This commit was merged in pull request #147.
This commit is contained in:
2026-06-25 07:26:21 +00:00
committed by Autin
parent 2e50a760c6
commit efded9fd40
22 changed files with 444 additions and 168 deletions
@@ -77,4 +77,23 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
// Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal). // Le libelle d'un site est son numero de departement (2 premiers chiffres du code postal).
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }]) expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
}) })
it('separe les categories CLIENT (formulaire) des categories ADRESSE (blocs adresse)', async () => {
// Le mock distingue les deux appels /categories par leur filtre typeCode.
mockGet.mockImplementation((url: string, query?: Record<string, unknown>) => {
if (url === '/categories' && query?.typeCode === 'CLIENT') {
return Promise.resolve({ member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }] })
}
if (url === '/categories' && query?.typeCode === 'ADRESSE') {
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'SIEGE', name: 'Siège' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useClientReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' }])
expect(refs.addressCategories.value).toEqual([{ value: '/api/categories/9', label: 'Siège', code: 'SIEGE' }])
})
}) })
@@ -23,6 +23,16 @@ describe('useSupplierReferentials', () => {
) )
}) })
it('charge les categories d\'adresse filtrees sur le type ADRESSE', async () => {
await useSupplierReferentials().loadCommon()
expect(mockGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'ADRESSE' }),
expect.objectContaining({ toast: false }),
)
})
it('mappe les categories en options { value: IRI, label: name, code }', async () => { it('mappe les categories en options { value: IRI, label: name, code }', async () => {
mockGet.mockImplementation((url: string) => { mockGet.mockImplementation((url: string) => {
if (url === '/categories') { if (url === '/categories') {
@@ -68,6 +68,9 @@ export function useClientReferentials() {
const api = useApi() const api = useApi()
const categories = ref<CategoryOption[]>([]) const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// CLIENT du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([]) const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
@@ -109,6 +112,9 @@ export function useClientReferentials() {
// de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API. // de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' }) fetchAll<CategoryMember>('/categories', { typeCode: 'CLIENT' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». Le code postal est deja // postal du site), ex: 86100 -> « 86 ». Le code postal est deja
@@ -151,6 +157,7 @@ export function useClientReferentials() {
return { return {
categories, categories,
addressCategories,
sites, sites,
tvaModes, tvaModes,
paymentDelays, paymentDelays,
@@ -62,6 +62,9 @@ export function useSupplierReferentials() {
const api = useApi() const api = useApi()
const categories = ref<CategoryOption[]>([]) const categories = ref<CategoryOption[]>([])
// Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories
// FOURNISSEUR du formulaire principal.
const addressCategories = ref<CategoryOption[]>([])
const sites = ref<RefOption[]>([]) const sites = ref<RefOption[]>([])
const tvaModes = ref<RefOption[]>([]) const tvaModes = ref<RefOption[]>([])
const paymentDelays = ref<RefOption[]>([]) const paymentDelays = ref<RefOption[]>([])
@@ -97,6 +100,9 @@ export function useSupplierReferentials() {
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API. // categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' }) fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
// Categories des blocs adresse : taxonomie dediee type ADRESSE.
fetchAll<CategoryMember>('/categories', { typeCode: 'ADRESSE' })
.then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites') fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code // Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ». // postal du site), ex: 86100 -> « 86 ».
@@ -121,6 +127,7 @@ export function useSupplierReferentials() {
return { return {
categories, categories,
addressCategories,
sites, sites,
tvaModes, tvaModes,
paymentDelays, paymentDelays,
@@ -479,9 +479,6 @@ import { readHistoryTab } from '~/shared/utils/historyTab'
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
@@ -573,15 +570,17 @@ function mergeOptions<T extends { value: string }>(primary: T[], extra: T[]): T[
return [...primary, ...extra.filter(o => !seen.has(o.value))] return [...primary, ...extra.filter(o => !seen.has(o.value))]
} }
const embedCategoryOptions = computed<CategoryOption[]>(() => { // Categories du formulaire principal (type CLIENT) : referentiel UNION categories
const fromClient = categoryOptionsOf(client.value?.categories) // embarquees du client (fallback si le referentiel n'est pas chargeable).
const fromAddresses = (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories)) const embedClientCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(client.value?.categories))
return mergeOptions(fromClient, fromAddresses) const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedClientCategoryOptions.value))
}) // Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value)) // embarquees des adresses (fallback meme fonction qu'au-dessus).
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29). const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
)
const addressCategoryOptions = computed(() => const addressCategoryOptions = computed(() =>
mainCategoryOptions.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)), mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
) )
const embedSiteOptions = computed<RefOption[]>(() => const embedSiteOptions = computed<RefOption[]>(() =>
@@ -456,9 +456,6 @@ const SIREN_MASK = '#########'
// Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7). // Masque « nombre » du champ Nombre de salaries : chiffres uniquement (max 7).
const EMPLOYEES_MASK = '#######' const EMPLOYEES_MASK = '#######'
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const toast = useToast() const toast = useToast()
@@ -816,10 +813,8 @@ async function submitContacts(): Promise<void> {
const addresses = ref<AddressFormDraft[]>([emptyAddress()]) const addresses = ref<AddressFormDraft[]>([emptyAddress()])
const addressDegradedNotified = ref(false) const addressDegradedNotified = ref(false)
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29). // Categories autorisees sur une adresse : taxonomie dediee type ADRESSE.
const addressCategoryOptions = computed(() => const addressCategoryOptions = computed(() => referentials.addressCategories.value)
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
)
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI). // Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
const contactOptions = computed<RefOption[]>(() => const contactOptions = computed<RefOption[]>(() =>
@@ -181,7 +181,7 @@
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1" :last="index === addresses.length - 1"
:category-options="mainCategoryOptions" :category-options="addressCategoryOptions"
:site-options="siteOptions" :site-options="siteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :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))] return [...primary, ...extra.filter(o => !seen.has(o.value))]
} }
// Categories issues de l'embed (fournisseur + adresses), role-independantes. // Categories du formulaire principal (type FOURNISSEUR) : referentiel UNION
const embedCategoryOptions = computed<CategoryOption[]>(() => { // categories embarquees du fournisseur (fallback si referentiel non chargeable).
const fromSupplier = categoryOptionsOf(supplier.value?.categories) const embedSupplierCategoryOptions = computed<CategoryOption[]>(() => categoryOptionsOf(supplier.value?.categories))
const fromAddresses = (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories)) const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedSupplierCategoryOptions.value))
return mergeOptions(fromSupplier, fromAddresses) // Categories des blocs adresse (type ADRESSE) : referentiel dedie UNION categories
}) // embarquees des adresses (meme logique de fallback).
// Toutes les categories de type FOURNISSEUR sont autorisees, sur le bloc principal const embedAddressCategoryOptions = computed<CategoryOption[]>(() =>
// comme sur une adresse (pas de restriction Distributeur/Courtier comme au M1 — RG-2.10). mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))),
const mainCategoryOptions = computed(() => mergeOptions(referentials.categories.value, embedCategoryOptions.value)) )
const addressCategoryOptions = computed(() =>
mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value),
)
const embedSiteOptions = computed<RefOption[]>(() => const embedSiteOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))), mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))),
@@ -179,7 +179,7 @@
:model-value="address" :model-value="address"
:title="t('commercial.suppliers.form.address.title', { n: index + 1 })" :title="t('commercial.suppliers.form.address.title', { n: index + 1 })"
:last="index === addresses.length - 1" :last="index === addresses.length - 1"
:category-options="referentials.categories.value" :category-options="referentials.addressCategories.value"
:site-options="referentials.sites.value" :site-options="referentials.sites.value"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
+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);
}
}
@@ -18,8 +18,10 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte * a leur CategoryType. Le type CLIENT porte ~11 categories clients (refonte
* taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs * taxonomie ERP-78) ; le type FOURNISSEUR porte les categories fournisseurs
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories * (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport). Chaque * prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
* categorie porte un `code` stable. * ADRESSE porte les categories des blocs adresse (Siege, Contact issues,
* Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte
* un `code` stable.
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des * Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29 * donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2). * (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
@@ -78,6 +80,14 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
'Nettoyage' => 'NETTOYAGE', 'Nettoyage' => 'NETTOYAGE',
'Transport' => 'TRANSPORT', 'Transport' => 'TRANSPORT',
], ],
'ADRESSE' => [
'Siège' => 'SIEGE',
'Contact issues' => 'CONTACT_ISSUES',
'Facturation' => 'FACTURATION',
'Livraison' => 'LIVRAISON',
'Approvisionnement' => 'APPROVISIONNEMENT',
'Méthaniseur' => 'METHANISEUR',
],
]; ];
public function __construct( public function __construct(
@@ -25,6 +25,10 @@ use Doctrine\Persistence\ObjectManager;
* taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage, * taxonomie distincte des prestataires (Maintenance industrielle, Nettoyage,
* Transport). Mirroir de la migration Version20260612080000. * Transport). Mirroir de la migration Version20260612080000.
* *
* ADRESSE : ajout du type ADRESSE (code ADRESSE, label « Adresse »), taxonomie
* dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir
* de la migration Version20260625100000.
*
* Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une * Pourquoi une fixture EN PLUS du seed de la migration : `category_type` est une
* entite managee par l ORM, donc le purger Doctrine la vide avant chaque * entite managee par l ORM, donc le purger Doctrine la vide avant chaque
* `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la * `doctrine:fixtures:load`. Sans cette fixture, le type CLIENT seede par la
@@ -41,12 +45,14 @@ class CategoryTypeFixtures extends Fixture
/** /**
* Source unique des types : code technique => libelle FR. Doit rester aligne * Source unique des types : code technique => libelle FR. Doit rester aligne
* sur le seed des migrations Version20260602100000 (CLIENT), * sur le seed des migrations Version20260602100000 (CLIENT),
* Version20260605120000 (FOURNISSEUR) et Version20260612080000 (PRESTATAIRE). * Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et
* Version20260625100000 (ADRESSE).
*/ */
private const TYPES = [ private const TYPES = [
'CLIENT' => 'Client', 'CLIENT' => 'Client',
'FOURNISSEUR' => 'Fournisseur', 'FOURNISSEUR' => 'Fournisseur',
'PRESTATAIRE' => 'Prestataire', 'PRESTATAIRE' => 'Prestataire',
'ADRESSE' => 'Adresse',
]; ];
public function __construct( public function __construct(
@@ -42,7 +42,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* - sites : SiteInterface (module Sites) via resolve_target_entities * - sites : SiteInterface (module Sites) via resolve_target_entities
* - contacts : ClientContact (meme module) * - contacts : ClientContact (meme module)
* - categories : CategoryInterface (module Catalog) via resolve_target_entities * - categories : CategoryInterface (module Catalog) via resolve_target_entities
* — codes DISTRIBUTEUR/COURTIER interdits (RG-1.29, validateCategoryCodes, ERP-78) * — type ADRESSE attendu (validateCategoryType)
* *
* Audite (#[Auditable]) + Timestampable/Blamable. * Audite (#[Auditable]) + Timestampable/Blamable.
* *
@@ -96,11 +96,11 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
/** /**
* RG-1.29 (ERP-78) : ces codes de categorie decrivent une relation entre * Seules les categories PORTANT ce type sont autorisees sur une adresse client.
* clients (distributeur / courtier) et n'ont pas de sens sur une adresse. * S'appuie sur CategoryInterface::getCategoryTypeCodes() (multi-type — pas
* Toute autre categorie du type CLIENT est autorisee. * d'import du module Catalog, regle ABSOLUE n°1).
*/ */
private const array FORBIDDEN_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']; private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -215,7 +215,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
private Collection $contacts; private Collection $contacts;
// Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse). // Au moins une categorie est obligatoire sur une adresse (spec-front § Adresse).
// RG-1.29 : categories de code DISTRIBUTEUR/COURTIER interdites (validateCategoryCodes). // Categories de type ADRESSE uniquement (validateCategoryType).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'client_address_category')] #[ORM\JoinTable(name: 'client_address_category')]
@@ -335,20 +335,19 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
} }
/** /**
* RG-1.29 (ERP-78) : une adresse interdit les categories de code * Toute categorie posee sur une adresse client doit etre de type ADRESSE ->
* DISTRIBUTEUR / COURTIER — elles decrivent une relation entre clients * sinon 422 avec violation sur le champ `categories`. S'appuie sur
* (RG-1.03) et n'ont pas de sens sur une adresse physique -> 422 avec * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
* violation sur le champ `categories`. Toute autre categorie (type unique * acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
* CLIENT) est acceptee. S'appuie sur CategoryInterface::getCode() (pas * regle ABSOLUE n°1).
* d'import du module Catalog — regle ABSOLUE n°1).
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryCodes(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
{ {
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& in_array($category->getCode(), self::FORBIDDEN_CATEGORY_CODES, true)) { && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé sur une adresse.') $context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
; ;
@@ -40,7 +40,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`. * un site obligatoire (RG-2.06, Assert\Count). Site n'a pas de `code`.
* - contacts : SupplierContact (meme module). * - contacts : SupplierContact (meme module).
* - categories : CategoryInterface (module Catalog) via resolve_target_entities — * - categories : CategoryInterface (module Catalog) via resolve_target_entities —
* type FOURNISSEUR attendu (RG-2.10, Assert\Callback validateCategoryType). * type ADRESSE attendu (Assert\Callback validateCategoryType).
* *
* Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read, * Embarquee sous `supplier.addresses` au detail (groupe supplier:item:read,
* maillon (a)). * maillon (a)).
@@ -110,11 +110,11 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU']; public const array ADDRESS_TYPES = ['PROSPECT', 'DEPART', 'RENDU'];
/** /**
* RG-2.10 : seules les categories PORTANT ce type sont autorisees sur une * Seules les categories PORTANT ce type sont autorisees sur une adresse
* adresse fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() * fournisseur. S'appuie sur CategoryInterface::getCategoryTypeCodes() (pas
* (pas d'import du module Catalog — regle ABSOLUE n°1). * d'import du module Catalog — regle ABSOLUE n°1).
*/ */
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR'; private const string REQUIRED_CATEGORY_TYPE_CODE = 'ADRESSE';
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -208,8 +208,8 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[Groups(['supplier:item:read', 'supplier:write:addresses'])] #[Groups(['supplier:item:read', 'supplier:write:addresses'])]
private Collection $contacts; private Collection $contacts;
// RG-2.10 : au moins une categorie de type FOURNISSEUR par adresse (le type est // Au moins une categorie de type ADRESSE par adresse (le type est controle par
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites). // validateCategoryType ; le minimum par Assert\Count, miroir sites).
/** @var Collection<int, CategoryInterface> */ /** @var Collection<int, CategoryInterface> */
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)] #[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
#[ORM\JoinTable(name: 'supplier_address_category')] #[ORM\JoinTable(name: 'supplier_address_category')]
@@ -227,12 +227,12 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
} }
/** /**
* RG-2.10 : toute categorie posee sur une adresse fournisseur doit etre de * Toute categorie posee sur une adresse fournisseur doit etre de type ADRESSE
* type FOURNISSEUR -> sinon 422 avec violation sur le champ `categories` * -> sinon 422 avec violation sur le champ `categories` (propertyPath aligne
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur * ERP-101, message FR ERP-107). S'appuie sur
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est * CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
* acceptee des qu'elle PORTE le type FOURNISSEUR ; pas d'import du module * acceptee des qu'elle PORTE le type ADRESSE ; pas d'import du module Catalog,
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform. * regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
*/ */
#[Assert\Callback] #[Assert\Callback]
public function validateCategoryType(ExecutionContextInterface $context): void public function validateCategoryType(ExecutionContextInterface $context): void
@@ -240,7 +240,7 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
foreach ($this->categories as $category) { foreach ($this->categories as $category) {
if ($category instanceof CategoryInterface if ($category instanceof CategoryInterface
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) { && !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).') $context->buildViolation('Type de catégorie non autorisé (ADRESSE attendu).')
->atPath('categories') ->atPath('categories')
->addViolation() ->addViolation()
; ;
@@ -55,8 +55,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by * Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
* restent null (« Systeme » cote front), c'est attendu. Les donnees respectent * restent null (« Systeme » cote front), c'est attendu. Les donnees respectent
* les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail * les CHECK BDD ET les validators applicatifs (exclusivite Prospect, billingEmail
* ssi facturation, aucune categorie de code DISTRIBUTEUR/COURTIER sur une adresse * ssi facturation, categories de type ADRESSE sur les adresses).
* — RG-1.29, ERP-78).
* *
* Depend de CategoryFixtures (categories), SitesFixtures (sites) et * Depend de CategoryFixtures (categories), SitesFixtures (sites) et
* CommercialReferentialFixtures (referentiels comptables Bank / PaymentType). * CommercialReferentialFixtures (referentiels comptables Bank / PaymentType).
@@ -116,7 +115,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
); );
if ($gsoIsNew) { if ($gsoIsNew) {
$this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr'); $this->addContact($gso, 'Paul', 'Garnier', 'Directeur commercial', '05 56 10 20 30', null, 'paul.garnier@distrib-gso.fr');
$this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Transport/Logistique']); $this->addAddress($gso, ['Pommevic'], '82400', 'Pommevic', '1 Av. Jean Duquesne', isDelivery: true, categoryNames: ['Livraison']);
} }
// Courtier reference par d'autres clients. // Courtier reference par d'autres clients.
@@ -140,7 +139,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$dubois->setPaymentType($this->paymentType($manager, 'VIREMENT')); $dubois->setPaymentType($this->paymentType($manager, 'VIREMENT'));
$dubois->setBank($this->bank($manager, 'SG')); $dubois->setBank($this->bank($manager, 'SG'));
$this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr'); $this->addContact($dubois, 'Jean', 'Dubois', 'Gérant', '05 49 00 00 01', null, 'jean.dubois@menuiserie-dubois.fr');
$this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['BTP']); $this->addAddress($dubois, ['Chatellerault'], '86100', 'Châtellerault', '12 rue de l\'Atelier', isDelivery: true, categoryNames: ['Livraison']);
} }
// === Dependant d'un distributeur (RG-1.03) === // === Dependant d'un distributeur (RG-1.03) ===
@@ -176,7 +175,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
if ($isNew) { if ($isNew) {
$transports->setPaymentType($this->paymentType($manager, 'LCR')); $transports->setPaymentType($this->paymentType($manager, 'LCR'));
$this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr'); $this->addContact($transports, null, 'Bernard', 'Responsable exploitation', '05 56 12 13 14', null, 'expl@transports-rapides.fr');
$this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Transport/Logistique']); $this->addAddress($transports, ['Saint-Jean'], '17400', 'Fontenet', '2 zone industrielle', isDelivery: true, categoryNames: ['Approvisionnement']);
$this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0); $this->addRib($transports, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
$this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1); $this->addRib($transports, 'Compte secondaire', 'SOGEFRPPXXX', 'FR7630006000011234567890189', 1);
} }
@@ -192,9 +191,9 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
// Prospect : exclusif de livraison/facturation (sans billingEmail). // Prospect : exclusif de livraison/facturation (sans billingEmail).
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0); $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '1 avenue de la Prospection', isProspect: true, position: 0);
// Livraison. // Livraison.
$this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Industrie'], position: 1); $this->addAddress($industries, ['Saint-Jean'], '17400', 'Fontenet', '4 rue de la Livraison', isDelivery: true, categoryNames: ['Livraison'], position: 1);
// Facturation : billingEmail obligatoire. // Facturation : billingEmail obligatoire.
$this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', position: 2); $this->addAddress($industries, ['Chatellerault'], '86100', 'Châtellerault', '7 boulevard des Factures', isBilling: true, billingEmail: 'Compta@Industries-Vertes.FR', categoryNames: ['Facturation'], position: 2);
} }
// === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) === // === 3 contacts dont un avec telephone secondaire (RG-1.05/1.02) ===
@@ -249,7 +248,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
$holding->setDirectorName('Antoine Lefèvre'); $holding->setDirectorName('Antoine Lefèvre');
$holding->setProfitAmount('1250000.00'); $holding->setProfitAmount('1250000.00');
$this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr'); $this->addContact($holding, 'Antoine', 'Lefèvre', 'PDG', '05 56 51 52 53', null, 'antoine.lefevre@holding-premium.fr');
$this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Industrie']); $this->addAddress($holding, ['Pommevic'], '82400', 'Pommevic', '1 allée des Investisseurs', isDelivery: true, categoryNames: ['Siège']);
} }
// === Multi-categories M2M === // === Multi-categories M2M ===
@@ -260,7 +259,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
); );
if ($isNew) { if ($isNew) {
$this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr'); $this->addContact($conglo, 'Hélène', 'Faure', 'Directrice générale', '05 49 61 62 63', null, 'helene.faure@conglomerat-multi.fr');
$this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['BTP', 'Services']); $this->addAddress($conglo, ['Chatellerault', 'Saint-Jean'], '86100', 'Châtellerault', '20 rue des Activités', isDelivery: true, categoryNames: ['Livraison', 'Approvisionnement']);
} }
// === Prospect seul === // === Prospect seul ===
@@ -282,7 +281,7 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
); );
if ($isNew) { if ($isNew) {
$this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr'); $this->addContact($association, null, 'Caron', 'Président', '05 49 81 82 83', null, 'president@asso-riverains.fr');
$this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Association']); $this->addAddress($association, ['Saint-Jean'], '17400', 'Fontenet', '6 chemin du Village', isDelivery: true, categoryNames: ['Contact issues']);
} }
$manager->flush(); $manager->flush();
@@ -359,10 +358,10 @@ class ClientFixtures extends Fixture implements DependentFixtureInterface
/** /**
* Ajoute une adresse au client (cascade persist via Client.addresses). Les * Ajoute une adresse au client (cascade persist via Client.addresses). Les
* donnees respectent les validators : exclusivite Prospect, billingEmail ssi * donnees respectent les validators : exclusivite Prospect, billingEmail ssi
* facturation, aucune categorie de code DISTRIBUTEUR/COURTIER (RG-1.29). * facturation, categories de type ADRESSE uniquement.
* *
* @param list<string> $siteNames au moins un site (RG-1.10) * @param list<string> $siteNames au moins un site (RG-1.10)
* @param list<string> $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29) * @param list<string> $categoryNames categories de type ADRESSE (Siege, Livraison...)
*/ */
private function addAddress( private function addAddress(
Client $client, Client $client,
@@ -28,9 +28,7 @@ interface CategoryInterface
* entre environnements) ni importer la classe concrete Category (regle * entre environnements) ni importer la classe concrete Category (regle
* ABSOLUE n°1). Pilote, cote M1 Commercial : * ABSOLUE n°1). Pilote, cote M1 Commercial :
* - RG-1.03 : un distributor doit referencer un client portant la categorie * - RG-1.03 : un distributor doit referencer un client portant la categorie
* de code DISTRIBUTEUR (resp. COURTIER pour broker) ; * de code DISTRIBUTEUR (resp. COURTIER pour broker).
* - RG-1.29 : une adresse interdit les categories de code DISTRIBUTEUR /
* COURTIER (relations entre clients, pas des attributs d'adresse).
*/ */
public function getCode(): ?string; public function getCode(): ?string;
@@ -38,9 +36,10 @@ interface CategoryInterface
* Codes des types de categorie rattaches (CategoryType::code), tableau vide * Codes des types de categorie rattaches (CategoryType::code), tableau vide
* si aucun. Depuis le passage en ManyToMany, une categorie peut porter * si aucun. Depuis le passage en ManyToMany, une categorie peut porter
* plusieurs types : un module tiers teste l'appartenance via * plusieurs types : un module tiers teste l'appartenance via
* `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote, cote * `in_array($code, $category->getCategoryTypeCodes(), true)`. Pilote la
* M2 Commercial, la RG-2.10 (une categorie de fournisseur doit etre de type * RG-2.10 (une categorie de fournisseur doit etre de type FOURNISSEUR) et la
* FOURNISSEUR). * validation des blocs adresse (categories de type ADRESSE uniquement, client
* comme fournisseur).
* *
* @return list<string> * @return list<string>
*/ */
@@ -271,9 +271,9 @@ final class ColumnCommentsCatalog
], ],
'client_address_category' => [ 'client_address_category' => [
'_table' => 'Jointure M2M client_address <-> category — categories d adresse (types SECTEUR/AUTRE uniquement, RG-1.29).', '_table' => 'Jointure M2M client_address <-> category — categories d adresse de type ADRESSE uniquement.',
'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.', 'client_address_id' => 'FK -> client_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse (type SECTEUR ou AUTRE, RG-1.29).', 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type ADRESSE.',
], ],
'client_rib' => [ 'client_rib' => [
@@ -360,9 +360,9 @@ final class ColumnCommentsCatalog
], ],
'supplier_address_category' => [ 'supplier_address_category' => [
'_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type FOURNISSEUR (RG-2.10).', '_table' => 'Jointure M2M supplier_address <-> category — categories d adresse de type ADRESSE.',
'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.', 'supplier_address_id' => 'FK -> supplier_address.id, ON DELETE CASCADE — adresse concernee.',
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type FOURNISSEUR (RG-2.10).', 'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type ADRESSE.',
], ],
'supplier_rib' => [ 'supplier_rib' => [
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
use App\Module\Catalog\Domain\Entity\CategoryType;
/**
* Tests du seed de la taxonomie ADRESSE cote API.
*
* Le multi-select « Categorie » des blocs adresse (client + fournisseur) consomme
* `GET /api/categories?typeCode=ADRESSE`. Ce test prouve que :
* - le filtre `?typeCode=ADRESSE` ne renvoie QUE les categories du type ADRESSE
* (aucune fuite de categorie d'un autre type) ;
* - chaque membre renvoye porte bien le type ADRESSE dans `categoryTypes`.
*
* NB : la base de test est purgee de toute categorie / type entre chaque test
* (cf. AbstractCatalogApiTestCase::cleanupCatalogTestData), donc le type et les
* categories ADRESSE sont materialises ici (et non lus depuis le seed de la
* migration / fixture, qui ne survit pas a la purge). On valide ainsi le contrat
* du filtre sur le code reel `ADRESSE`. La presence du seed apres un
* `make db-reset` reel est, elle, verifiee par l'idempotence des fixtures.
*
* @internal
*/
final class CategoryAdresseSeedTest extends AbstractCatalogApiTestCase
{
/**
* Categories de demonstration seedees par la migration / fixture ADRESSE.
*/
private const array ADDRESS_CATEGORIES = [
'Siège',
'Contact issues',
'Facturation',
'Livraison',
'Approvisionnement',
'Méthaniseur',
];
public function testTypeCodeAdresseReturnsOnlyAddressCategories(): void
{
$addressType = $this->getOrCreateAdresseType();
foreach (self::ADDRESS_CATEGORIES as $name) {
$this->createCategory($name, $addressType);
}
// Bruit : un type + une categorie d'un autre type ne doivent PAS fuiter.
$noiseType = $this->createCategoryType('TEST_CLIENT', 'Test Client');
$this->createCategory(self::TEST_CATEGORY_PREFIX.'noise', $noiseType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=ADRESSE&pagination=false');
self::assertSame(200, $response->getStatusCode());
$members = $response->toArray()['member'];
$names = array_map(static fn (array $m): string => $m['name'], $members);
sort($names);
$expected = self::ADDRESS_CATEGORIES;
sort($expected);
self::assertSame(
$expected,
$names,
'Le filtre ?typeCode=ADRESSE doit ne renvoyer QUE les categories du type ADRESSE.',
);
// Chaque categorie remontee doit PORTER le type ADRESSE.
foreach ($members as $member) {
self::assertContains('ADRESSE', array_column($member['categoryTypes'], 'code'));
}
}
public function testTypeCodeAdresseKeepsHydraPagination(): void
{
$addressType = $this->getOrCreateAdresseType();
$this->createCategory('Siège', $addressType);
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/categories?typeCode=ADRESSE');
self::assertSame(200, $response->getStatusCode());
$data = $response->toArray();
self::assertArrayHasKey('totalItems', $data, 'Le filtre ne doit pas casser la pagination Hydra.');
self::assertArrayHasKey('member', $data);
foreach ($data['member'] as $member) {
self::assertContains('ADRESSE', array_column($member['categoryTypes'], 'code'));
}
}
/**
* Recupere le type ADRESSE reel, ou le cree s'il est absent. Le code `ADRESSE`
* est seede par CategoryTypeFixtures (present en debut de suite), mais le
* cleanup purge tous les `category_type` entre les tests : selon l'ordre
* d'execution, le type peut donc exister ou non. Le get-or-create rend le test
* robuste sans dependre du seed ni le dupliquer.
*/
private function getOrCreateAdresseType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'ADRESSE']);
if ($existing instanceof CategoryType) {
return $existing;
}
return $this->createCategoryType('ADRESSE', 'Adresse');
}
}
@@ -36,10 +36,10 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_'; protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
/** /**
* Codes pilotant les RG (RG-1.03 distributor/broker, RG-1.29 adresse) : ils * Codes pilotant les RG (RG-1.03 distributor/broker) : ils doivent matcher
* doivent matcher exactement, donc createCategory() les fetch-or-create par * exactement, donc createCategory() les fetch-or-create par code. Les autres
* code. Les autres codes sont traites comme de simples libelles generiques et * codes sont traites comme de simples libelles generiques et produisent une
* produisent une categorie a code UNIQUE (cf. createCategory). * categorie a code UNIQUE (cf. createCategory).
*/ */
private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER']; private const array RG_EXACT_CODES = ['DISTRIBUTEUR', 'COURTIER'];
@@ -75,6 +75,47 @@ abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
return $type; return $type;
} }
/**
* Recupere (ou cree) le type ADRESSE (categories des blocs adresse). Idempotent
* via l'unicite de category_type.code. Laisse en place au tearDown.
*/
protected function addressCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'ADRESSE']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('ADRESSE');
$type->setLabel('Adresse');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Cree une Category de test de type ADRESSE (autorisee sur un bloc adresse).
* Code UNIQUE (suffixe aleatoire) : les categories d'adresse ne pilotent aucune
* RG par code, deux appels produisent donc deux categories distinctes.
*/
protected function createAddressCategory(): Category
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'adresse_'.$suffix);
$category->setCode('ADRESSE_'.strtoupper($suffix));
$category->addCategoryType($this->addressCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/** /**
* Cree une Category de test sous le type unique CLIENT (ERP-78). * Cree une Category de test sous le type unique CLIENT (ERP-78).
* *
@@ -134,8 +134,8 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
* Seede un fournisseur COMPLET (sans passer par l'API — validations * Seede un fournisseur COMPLET (sans passer par l'API — validations
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information * applicatives non rejouees mais CHECK BDD respectes) : onglet Information
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse * rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie * multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie de type
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle * ADRESSE, >= 1 contact, >= 1 categorie FOURNISSEUR sur le fournisseur. Sert de socle
* au contrat de serialisation et a la DoD (§ 4.0.bis). * au contrat de serialisation et a la DoD (§ 4.0.bis).
* *
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR, * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
@@ -202,7 +202,9 @@ abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
foreach ($sites as $site) { foreach ($sites as $site) {
$address->addSite($site); $address->addSite($site);
} }
$address->addCategory($this->supplierCategory('NEGOCIANT')); // Categorie de bloc adresse : type ADRESSE (et non FOURNISSEUR — celui-ci
// reste sur le bloc principal du fournisseur).
$address->addCategory($this->createAddressCategory());
$address->addContact($contact); $address->addContact($contact);
$supplier->addAddress($address); $supplier->addAddress($address);
$em->persist($address); $em->persist($address);
@@ -15,8 +15,8 @@ use App\Module\Sites\Domain\Entity\Site;
* - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs * - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs
* is_delivery / is_billing ; * is_delivery / is_billing ;
* - RG-1.11 : billing_email obligatoire ssi is_billing ; * - RG-1.11 : billing_email obligatoire ssi is_billing ;
* - RG-1.29 (ERP-78) : les categories de code DISTRIBUTEUR / COURTIER sont * - categorie d'adresse : seules les categories de type ADRESSE sont acceptees
* interdites sur une adresse (-> 422) ; toute autre categorie est acceptee. * (-> 422 sinon), au moins une est obligatoire.
* *
* Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite * Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite
* ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide * ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide
@@ -170,7 +170,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Non Billing Empty Email'); $seed = $this->seedClient('Non Billing Empty Email');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -197,7 +197,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Billing Two Emails'); $seed = $this->seedClient('Billing Two Emails');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -225,7 +225,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Secondary Email Non Billing'); $seed = $this->seedClient('Secondary Email Non Billing');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -246,15 +246,16 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
} }
/** /**
* RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 * Une categorie qui n'est PAS de type ADRESSE (ici une categorie CLIENT) est
* avec violation sur le champ `categories`. * refusee sur une adresse -> 422 avec violation sur le champ `categories`.
*/ */
public function testAddressRejectsDistributorCategory(): void public function testAddressRejectsNonAddressCategory(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Cat'); $seed = $this->seedClient('Address Non Address Cat');
$category = $this->createCategory('DISTRIBUTEUR'); // Categorie de type CLIENT (et non ADRESSE) -> doit etre refusee sur l'adresse.
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -270,70 +271,20 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertStringContainsString( self::assertStringContainsString(
'Type de catégorie non autorisé sur une adresse.', 'Type de catégorie non autorisé (ADRESSE attendu).',
(string) $client->getResponse()->getContent(false), (string) $client->getResponse()->getContent(false),
); );
} }
/** /**
* RG-1.29 : poster une categorie de type COURTIER sur une adresse -> 422. * Une categorie de type ADRESSE est acceptee sur une adresse -> 201.
*/ */
public function testAddressRejectsBrokerCategory(): void public function testAddressAcceptsAddressCategory(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Cat'); $seed = $this->seedClient('Address Address Cat');
$category = $this->createCategory('COURTIER'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* RG-1.29 : une categorie de type SECTEUR est autorisee sur une adresse.
*/
public function testAddressAcceptsSectorCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Sector Cat');
$category = $this->createCategory('SECTEUR');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'isDelivery' => true,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(201);
}
/**
* RG-1.29 : une categorie de type AUTRE est autorisee sur une adresse.
*/
public function testAddressAcceptsOtherCategory(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedClient('Address Other Cat');
$category = $this->createCategory('AUTRE');
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -385,7 +336,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address No Type'); $seed = $this->seedClient('Address No Type');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -413,7 +364,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Type'); $seed = $this->seedClient('Address Broker Type');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -435,7 +386,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Distributor Type'); $seed = $this->seedClient('Address Distributor Type');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -462,7 +413,7 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Broker Mix'); $seed = $this->seedClient('Address Broker Mix');
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -203,7 +203,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Address Host'); $seed = $this->seedClient('Address Host');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -276,7 +276,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedClient('Addr Multi'); $seed = $this->seedClient('Addr Multi');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$this->seedAddress($seed, 'Bordeaux'); $this->seedAddress($seed, 'Bordeaux');
$this->seedAddress($seed, 'Lyon'); $this->seedAddress($seed, 'Lyon');
@@ -305,7 +305,7 @@ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->createCategory('SECTEUR'); $category = $this->createAddressCategory();
$client->request('POST', '/api/clients/999999/addresses', [ $client->request('POST', '/api/clients/999999/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
@@ -106,7 +106,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Host'); $seed = $this->seedSupplier('Address Host');
$category = $this->supplierCategory('NEGOCIANT'); $category = $this->createAddressCategory();
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
@@ -174,7 +174,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Incoherent'); $seed = $this->seedSupplier('Address Incoherent');
$category = $this->supplierCategory('NEGOCIANT'); $category = $this->createAddressCategory();
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur. // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -222,7 +222,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types'); $seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri(); $siteIri = $this->firstSiteIri();
$category = $this->supplierCategory('NEGOCIANT'); $category = $this->createAddressCategory();
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) { foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -240,12 +240,12 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
} }
} }
public function testPostAddressWithNonFournisseurCategoryReturns422(): void public function testPostAddressWithNonAddressCategoryReturns422(): void
{ {
$this->skipIfSitesModuleDisabled(); $this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad Cat'); $seed = $this->seedSupplier('Address Bad Cat');
// categorie de type CLIENT -> interdite sur une adresse fournisseur. // categorie de type CLIENT (et non ADRESSE) -> interdite sur une adresse.
$clientTypedCategory = $this->createCategory('SECTEUR'); $clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
@@ -260,7 +260,7 @@ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
], ],
]); ]);
// RG-2.10 -> 422 rattachee a categories. // Categorie hors type ADRESSE -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
} }