From efded9fd404cec259e5ae78749d28a0ed25a4100 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 25 Jun 2026 07:26:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(commercial)=20:=20cat=C3=A9gories=20de=20t?= =?UTF-8?q?ype=20Adresse=20pour=20les=20blocs=20adresse=20(client=20+=20fo?= =?UTF-8?q?urnisseur)=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/147 Co-authored-by: tristan Co-committed-by: tristan --- .../__tests__/useClientReferentials.spec.ts | 19 +++ .../__tests__/useSupplierReferentials.spec.ts | 10 ++ .../composables/useClientReferentials.ts | 7 ++ .../composables/useSupplierReferentials.ts | 7 ++ .../commercial/pages/clients/[id]/edit.vue | 21 ++-- .../modules/commercial/pages/clients/new.vue | 9 +- .../commercial/pages/suppliers/[id]/edit.vue | 23 ++-- .../commercial/pages/suppliers/new.vue | 2 +- migrations/Version20260625100000.php | 119 ++++++++++++++++++ .../DataFixtures/CategoryFixtures.php | 14 ++- .../DataFixtures/CategoryTypeFixtures.php | 8 +- .../Domain/Entity/ClientAddress.php | 29 +++-- .../Domain/Entity/SupplierAddress.php | 26 ++-- .../DataFixtures/ClientFixtures.php | 23 ++-- .../Domain/Contract/CategoryInterface.php | 11 +- .../Database/ColumnCommentsCatalog.php | 8 +- .../Catalog/Api/CategoryAdresseSeedTest.php | 110 ++++++++++++++++ .../Api/AbstractCommercialApiTestCase.php | 49 +++++++- .../Api/AbstractSupplierApiTestCase.php | 8 +- .../Commercial/Api/ClientAddressTest.php | 91 ++++---------- .../Api/ClientSubResourceApiTest.php | 6 +- .../Api/SupplierSubResourceApiTest.php | 12 +- 22 files changed, 444 insertions(+), 168 deletions(-) create mode 100644 migrations/Version20260625100000.php create mode 100644 tests/Module/Catalog/Api/CategoryAdresseSeedTest.php diff --git a/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts index 8508cdc..3991e40 100644 --- a/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts +++ b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts @@ -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) => { + 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' }]) + }) }) diff --git a/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts b/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts index c25eb73..4f1cf24 100644 --- a/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts +++ b/frontend/modules/commercial/composables/__tests__/useSupplierReferentials.spec.ts @@ -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') { diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 8e90fb6..6799f79 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -68,6 +68,9 @@ export function useClientReferentials() { const api = useApi() const categories = ref([]) + // Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories + // CLIENT du formulaire principal. + const addressCategories = ref([]) const sites = ref([]) const tvaModes = ref([]) const paymentDelays = ref([]) @@ -109,6 +112,9 @@ export function useClientReferentials() { // de type CLIENT (pas FOURNISSEUR) -> on filtre la collection cote API. fetchAll('/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('/categories', { typeCode: 'ADRESSE' }) + .then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), fetchAll('/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, diff --git a/frontend/modules/commercial/composables/useSupplierReferentials.ts b/frontend/modules/commercial/composables/useSupplierReferentials.ts index 8636ce3..01ef814 100644 --- a/frontend/modules/commercial/composables/useSupplierReferentials.ts +++ b/frontend/modules/commercial/composables/useSupplierReferentials.ts @@ -62,6 +62,9 @@ export function useSupplierReferentials() { const api = useApi() const categories = ref([]) + // Taxonomie dediee aux blocs adresse (type ADRESSE), distincte des categories + // FOURNISSEUR du formulaire principal. + const addressCategories = ref([]) const sites = ref([]) const tvaModes = ref([]) const paymentDelays = ref([]) @@ -97,6 +100,9 @@ export function useSupplierReferentials() { // categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API. fetchAll('/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('/categories', { typeCode: 'ADRESSE' }) + .then((cats) => { addressCategories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), fetchAll('/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, diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index 768dacd..5a67243 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -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(primary: T[], extra: T[]): T[ return [...primary, ...extra.filter(o => !seen.has(o.value))] } -const embedCategoryOptions = computed(() => { - 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(() => 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(() => + 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(() => diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index a07840d..c7384da 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -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 { const addresses = ref([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(() => diff --git a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue index d5af610..abc8137 100644 --- a/frontend/modules/commercial/pages/suppliers/[id]/edit.vue +++ b/frontend/modules/commercial/pages/suppliers/[id]/edit.vue @@ -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(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(() => { - 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(() => 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(() => + mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => categoryOptionsOf(a.categories))), +) +const addressCategoryOptions = computed(() => + mergeOptions(referentials.addressCategories.value, embedAddressCategoryOptions.value), +) const embedSiteOptions = computed(() => mergeOptions([], (supplier.value?.addresses ?? []).flatMap(a => siteOptionsOf(a.sites))), diff --git a/frontend/modules/commercial/pages/suppliers/new.vue b/frontend/modules/commercial/pages/suppliers/new.vue index a363aeb..7d0f6fc 100644 --- a/frontend/modules/commercial/pages/suppliers/new.vue +++ b/frontend/modules/commercial/pages/suppliers/new.vue @@ -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" diff --git a/migrations/Version20260625100000.php b/migrations/Version20260625100000.php new file mode 100644 index 0000000..be9bde1 --- /dev/null +++ b/migrations/Version20260625100000.php @@ -0,0 +1,119 @@ + 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); + } +} diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php index b150a45..794ff64 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryFixtures.php @@ -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( diff --git a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php index 8d5c518..2b45d7c 100644 --- a/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php +++ b/src/Module/Catalog/Infrastructure/DataFixtures/CategoryTypeFixtures.php @@ -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( diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index 6e35abd..80e0704 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -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 */ #[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() ; diff --git a/src/Module/Commercial/Domain/Entity/SupplierAddress.php b/src/Module/Commercial/Domain/Entity/SupplierAddress.php index 45984dd..838ba5c 100644 --- a/src/Module/Commercial/Domain/Entity/SupplierAddress.php +++ b/src/Module/Commercial/Domain/Entity/SupplierAddress.php @@ -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 */ #[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() ; diff --git a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php index 2b741e2..4fdce01 100644 --- a/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php +++ b/src/Module/Commercial/Infrastructure/DataFixtures/ClientFixtures.php @@ -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 $siteNames au moins un site (RG-1.10) - * @param list $categoryNames categories hors DISTRIBUTEUR/COURTIER (RG-1.29) + * @param list $categoryNames categories de type ADRESSE (Siege, Livraison...) */ private function addAddress( Client $client, diff --git a/src/Shared/Domain/Contract/CategoryInterface.php b/src/Shared/Domain/Contract/CategoryInterface.php index 21ff609..87e4368 100644 --- a/src/Shared/Domain/Contract/CategoryInterface.php +++ b/src/Shared/Domain/Contract/CategoryInterface.php @@ -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 */ diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 6a69032..5a8415b 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -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' => [ diff --git a/tests/Module/Catalog/Api/CategoryAdresseSeedTest.php b/tests/Module/Catalog/Api/CategoryAdresseSeedTest.php new file mode 100644 index 0000000..c458f52 --- /dev/null +++ b/tests/Module/Catalog/Api/CategoryAdresseSeedTest.php @@ -0,0 +1,110 @@ +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'); + } +} diff --git a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php index 83b834e..c6a4616 100644 --- a/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractCommercialApiTestCase.php @@ -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). * diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php index 97f4270..8eb7792 100644 --- a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php +++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php @@ -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); diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 9e8f8fa..f930d46 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -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], diff --git a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php index 08ef6b7..438b45e 100644 --- a/tests/Module/Commercial/Api/ClientSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/ClientSubResourceApiTest.php @@ -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], diff --git a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php index ac1c8b8..1a90a08 100644 --- a/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php +++ b/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php @@ -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))); }