fix(front+back) : suppression de la categorie du bloc adresse prestataire (ERP-193)
La categorie portee par l'ADRESSE du prestataire (M2M provider_address_category) est retiree de toutes les couches : champ + prop du bloc adresse, type/payload/ mapping front, entite ProviderAddress (M2M, Assert\Count, validateCategoryType, accesseurs), fixtures, contexte de serialisation. Nouvelle migration de drop de la table (namespace racine pour l'ordre post-creation). La categorie du PRESTATAIRE lui-meme (provider_category, repertoire, filtre, formulaire principal) est conservee.
This commit is contained in:
@@ -453,7 +453,6 @@
|
||||
},
|
||||
"address": {
|
||||
"sites": "Sites",
|
||||
"categories": "Catégorie",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
|
||||
@@ -23,19 +23,6 @@
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
@@ -151,8 +138,6 @@ const POSTAL_CODE_MASK = '#####'
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
|
||||
@@ -46,7 +46,6 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
@@ -79,17 +78,14 @@ describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/tri
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
it('affiche l\'erreur serveur sur sites (RG-3.05)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
|
||||
@@ -330,17 +330,16 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||
/** Remplit un bloc adresse valide (site + scalaires requis). */
|
||||
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||
const a = addressAt(form, index)
|
||||
a.siteIris = [SITE_86]
|
||||
a.categoryIris = [CAT_MAINT]
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que le site manque', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
@@ -349,8 +348,6 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
|
||||
addressAt(form).siteIris = [SITE_86]
|
||||
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||
addressAt(form).categoryIris = [CAT_MAINT]
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
@@ -377,7 +374,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/addresses')
|
||||
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||
expect(body).toMatchObject({ sites: [SITE_86], city: 'Châtellerault' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(addressAt(form).id).toBe(88)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="index"
|
||||
:model-value="view.draft"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="view.siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptionsFor(view.draft.country)"
|
||||
@@ -242,16 +241,15 @@ const contacts = computed(() => {
|
||||
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites embarques).
|
||||
const addressViews = computed(() => {
|
||||
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}))
|
||||
return views.length > 0
|
||||
? views
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [] }]
|
||||
})
|
||||
|
||||
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
|
||||
@@ -97,8 +97,6 @@ export interface ProviderAddressFormDraft {
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||
@@ -114,7 +112,6 @@ export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
}
|
||||
|
||||
@@ -12,21 +12,15 @@ import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
*/
|
||||
describe('providerAddress helpers', () => {
|
||||
const SITE = '/api/sites/1'
|
||||
const CAT = '/api/categories/7'
|
||||
|
||||
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||
describe('isProviderAddressValid (RG-3.05)', () => {
|
||||
it('false sans site', () => {
|
||||
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||
const address = { ...emptyProviderAddress() }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('false sans categorie', () => {
|
||||
it('true avec au moins un site', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('true avec au moins un site ET une categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -39,7 +33,6 @@ describe('providerAddress helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
contactIris: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
@@ -48,7 +41,6 @@ describe('providerAddress helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
categories: [CAT],
|
||||
sites: [SITE],
|
||||
contacts: ['/api/provider_contacts/9'],
|
||||
})
|
||||
@@ -61,7 +53,6 @@ describe('providerAddress helpers', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('postalCode')
|
||||
expect(payload).not.toHaveProperty('city')
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('providerDetail helpers', () => {
|
||||
})
|
||||
|
||||
describe('mapAddressToDraft', () => {
|
||||
it('extrait les IRI des sites / categories / contacts embarques', () => {
|
||||
it('extrait les IRI des sites / contacts embarques', () => {
|
||||
const draft = mapAddressToDraft({
|
||||
'@id': '/api/provider_addresses/3',
|
||||
id: 3,
|
||||
@@ -85,11 +85,9 @@ describe('providerDetail helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
sites: [{ '@id': '/api/sites/1' }],
|
||||
categories: [{ '@id': '/api/categories/7' }],
|
||||
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||
})
|
||||
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/7'])
|
||||
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
|
||||
expect(draft.id).toBe(3)
|
||||
})
|
||||
|
||||
@@ -14,12 +14,12 @@ import type { ProviderAddressFormDraft } from '~/modules/technique/types/provide
|
||||
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||
|
||||
/**
|
||||
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||
* RG-3.05 : une adresse est « valide » pour autoriser l'ajout d'un nouveau bloc
|
||||
* des qu'elle porte au moins un site. Les scalaires (CP/ville/rue) restent valides
|
||||
* par le back (422 inline).
|
||||
*/
|
||||
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||
return address.siteIris.length >= 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,6 @@ export function buildProviderAddressPayload(address: ProviderAddressFormDraft):
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: [...address.categoryIris],
|
||||
sites: [...address.siteIris],
|
||||
contacts: [...address.contactIris],
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface AddressRead extends HydraRef {
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
@@ -146,7 +145,6 @@ export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraf
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-193 (retours metier M3) : suppression du champ « Categorie » du BLOC ADRESSE
|
||||
* prestataire (ProviderAddress) — fonctionnalite jugee inutile cote metier. Seule
|
||||
* la categorie du PRESTATAIRE lui-meme (table provider_category) est conservee.
|
||||
*
|
||||
* Drop de la table de jointure M2M provider_address_category (creee par
|
||||
* Version20260612100000). Migration au namespace racine DoctrineMigrations (et non
|
||||
* modulaire Technique) : elle DEPEND d une table creee au namespace racine et doit
|
||||
* donc s executer APRES sur base vide. Le tri cross-namespace de Doctrine Migrations
|
||||
* est alphabetique par FQCN (cf. regle ABSOLUE n°11) : une migration modulaire
|
||||
* « App\... » trierait AVANT « DoctrineMigrations\... » et passerait le DROP avant
|
||||
* le CREATE (table recreee a la fin). Rester en racine garantit l ordre par version.
|
||||
*/
|
||||
final class Version20260622100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-193 : suppression de la categorie du bloc adresse prestataire (drop provider_address_category).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_category (
|
||||
provider_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, category_id),
|
||||
CONSTRAINT fk_provider_address_category_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -278,8 +278,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type
|
||||
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* ProviderAddress::validateCategoryType. S'appuie sur
|
||||
* (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 PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
|
||||
|
||||
@@ -14,7 +14,6 @@ use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddr
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
@@ -24,21 +23,17 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Adresse d'un prestataire (1:n) — onglet Adresse. Version SIMPLIFIEE de
|
||||
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
|
||||
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts /
|
||||
* categories.
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts.
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
|
||||
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
|
||||
* - contacts : ProviderContact (meme module).
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
||||
* maillon (a)).
|
||||
@@ -50,7 +45,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06 sont portees par les
|
||||
* contraintes de l'entite (jouees avant le processor).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
@@ -59,9 +54,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// site:read + category:read : embarquent les Site / Category lies
|
||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
// site:read : embarque les Site lies (maillon (c)) plutot que des IRI
|
||||
// nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
@@ -76,13 +71,13 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
@@ -102,13 +97,6 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -175,46 +163,10 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
|
||||
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_address_category')]
|
||||
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
|
||||
* type PRESTATAIRE -> 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 PRESTATAIRE ; 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
|
||||
{
|
||||
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é (PRESTATAIRE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -349,26 +301,4 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -33,8 +33,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
|
||||
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
|
||||
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
|
||||
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
|
||||
* ProviderAddress::validateCategoryType).
|
||||
* Assert\Count).
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
|
||||
@@ -63,7 +63,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
|
||||
* Type de categorie exige pour un prestataire (RG-3.09).
|
||||
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
|
||||
*/
|
||||
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
@@ -117,7 +117,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||
$maintenance->setBank($this->bank($manager, 'SG'));
|
||||
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias');
|
||||
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
|
||||
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs');
|
||||
}
|
||||
|
||||
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
|
||||
@@ -237,8 +237,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
|
||||
* PRESTATAIRE (RG-3.09).
|
||||
*
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
*/
|
||||
private function addAddress(
|
||||
Provider $provider,
|
||||
@@ -247,7 +246,6 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
string $city,
|
||||
string $street,
|
||||
?string $streetComplement = null,
|
||||
array $categoryNames = [],
|
||||
int $position = 0,
|
||||
): void {
|
||||
$address = new ProviderAddress();
|
||||
@@ -262,9 +260,6 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
foreach ($siteNames as $siteName) {
|
||||
$address->addSite($this->site($siteName));
|
||||
}
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$address->addCategory($this->category($this->manager, $categoryName));
|
||||
}
|
||||
|
||||
$provider->addAddress($address);
|
||||
}
|
||||
|
||||
@@ -444,12 +444,6 @@ final class ColumnCommentsCatalog
|
||||
'provider_contact_id' => 'FK -> provider_contact.id, ON DELETE CASCADE — contact associe a l adresse.',
|
||||
],
|
||||
|
||||
'provider_address_category' => [
|
||||
'_table' => 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).',
|
||||
'provider_address_id' => 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.',
|
||||
'category_id' => 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).',
|
||||
],
|
||||
|
||||
'provider_rib' => [
|
||||
'_table' => 'Coordonnees bancaires d un prestataire (1:n) — >= 1 RIB attendu selon le type de reglement (RG-3.08). Tous les champs audites (pas d AuditIgnore).',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
|
||||
@@ -388,7 +388,6 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
||||
foreach ($sites as $site) {
|
||||
$address->addSite($site);
|
||||
}
|
||||
$address->addCategory($this->providerCategory('NETTOYAGE'));
|
||||
$address->addContact($contact);
|
||||
$provider->addAddress($address);
|
||||
$em->persist($address);
|
||||
|
||||
@@ -162,11 +162,6 @@ final class ProviderSerializationContractTest extends AbstractProviderApiTestCas
|
||||
self::assertArrayHasKey('code', $category);
|
||||
self::assertArrayHasKey('name', $category);
|
||||
self::assertSame('NETTOYAGE', $category['code']);
|
||||
|
||||
// Categories d'adresse aussi (category:read dans le contexte du detail).
|
||||
self::assertArrayHasKey('categories', $data['addresses'][0]);
|
||||
self::assertNotEmpty($data['addresses'][0]['categories']);
|
||||
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
|
||||
}
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInList(): void
|
||||
|
||||
@@ -10,7 +10,7 @@ use App\Module\Technique\Domain\Entity\Provider;
|
||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
||||
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
|
||||
* (au moins le prenom OU le nom — aligne M1/M2), RG-3.05 (>= 1 site sur
|
||||
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
|
||||
* l'adresse), RG-3.06 (code postal),
|
||||
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
|
||||
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
|
||||
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
|
||||
@@ -166,9 +166,8 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
|
||||
public function testPostAddressWithValidPayloadReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Host');
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Host');
|
||||
|
||||
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -177,7 +176,6 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
@@ -197,7 +195,6 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [],
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -217,7 +214,6 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -225,33 +221,10 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Bad Cat');
|
||||
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
|
||||
|
||||
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$foreign->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-3.09 -> 422 rattachee a categories.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
public function testDeleteAddressReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Delete');
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedProvider('Address Delete');
|
||||
|
||||
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
@@ -260,7 +233,6 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
@@ -294,8 +266,7 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
*/
|
||||
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
|
||||
{
|
||||
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
|
||||
$category = $this->providerCategory('NETTOYAGE');
|
||||
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
|
||||
|
||||
$creds = $this->createScopedUser(
|
||||
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
|
||||
@@ -311,7 +282,6 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
|
||||
'city' => 'Saint-Jean-d\'Angély',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user