feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

Cree la table country (referentiel statique : code ISO alpha-2, name, position)
seedee avec 6 pays (France, Allemagne, Belgique, Espagne, Italie, Royaume-Uni),
exposee en lecture seule via /api/countries (GetCollection + Get, gating aligne
sur Bank). Perimetre minimal volontaire : aucune longueur bancaire/fiscale a ce
stade (iteration ulterieure du ticket).

Front : la liste des pays jusqu'ici codee en dur dans les 3 ecrans clients est
remplacee par le referentiel charge via useClientReferentials (value = nom du
pays, l'adresse continuant de stocker country en chaine libre : pas de FK ni de
migration de donnees). Consultation : options derivees de l'embed.

Garde-fous : country ajoute a ColumnCommentsCatalog + whitelist
EntitiesAreTimestampableBlamableTest ; tests API dedies (200/seed/405/403/401).
This commit is contained in:
Matthieu
2026-06-09 10:31:34 +02:00
parent d4a5df50a7
commit e40e053950
13 changed files with 426 additions and 17 deletions
@@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
}
if (url === '/countries') {
// Pays : value === label === name (l'adresse stocke le nom).
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
}
return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
})
@@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI).
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
// Seul le select en echec reste vide.
expect(refs.categories.value).toEqual([])
@@ -3,7 +3,7 @@ import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
* reglement, banques, et les listes distributeurs / courtiers.
* reglement, banques, pays, et les listes distributeurs / courtiers.
*
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
@@ -57,6 +57,11 @@ interface ClientMember extends HydraMember {
companyName: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() {
@@ -68,6 +73,7 @@ export function useClientReferentials() {
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([])
@@ -116,6 +122,12 @@ export function useClientReferentials() {
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
@@ -144,6 +156,7 @@ export function useClientReferentials() {
paymentDelays,
paymentTypes,
banks,
countries,
distributors,
brokers,
loadCommon,
@@ -554,10 +554,10 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de
// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke
// `country` en chaine libre, donc value === label).
const countryOptions = computed<RefOption[]>(() => referentials.countries.value)
const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -384,10 +384,18 @@ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
// GET /countries, sur le meme principe que les autres selects de consultation
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
// lecture seule.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
@@ -783,11 +783,10 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en
// remplacement de l'ancienne liste codee en dur. France reste preselectionnee
// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse).
const countryOptions = computed<RefOption[]>(() => referentials.countries.value)
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse.
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-116 — Referentiel Pays (Country), 1re iteration : creation de la table
* `country` + seed des 6 pays (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni). Devient la source unique du select pays, en remplacement de la
* liste codee en dur cote front.
*
* Perimetre minimal voulu : code ISO 3166-1 alpha-2 + libelle FR + ordre
* d'affichage UNIQUEMENT. Aucune longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade — iteration ulterieure du meme ticket.
*
* Pas de FK posee sur les adresses (client_address.country / supplier_address)
* a cette etape : ces colonnes restent des chaines libres (« France »...), donc
* aucune migration de donnees ni rupture de l'existant.
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE Starseed n°11) comme les
* migrations M1/M2 du module Commercial : pas de migrations_path modulaire
* configure pour Commercial, et le tri par timestamp reste garanti.
*
* Seed idempotent `ON CONFLICT (code) DO NOTHING` : la table peut deja porter
* des donnees en prod lors d'un rejeu. Chaque colonne porte un `COMMENT ON
* COLUMN` (regle ABSOLUE n°12, garde-fou ColumnsHaveSqlCommentTest) ; la table
* est aussi mirroree dans ColumnCommentsCatalog pour survivre au
* `schema:update --force` du setup de test.
*/
final class Version20260609100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-116 : table country (referentiel pays) + seed des 6 pays.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE country (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(2) NOT NULL,
name VARCHAR(80) NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE UNIQUE INDEX uq_country_code ON country (code)');
$this->comment('country', '_table', 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).');
$this->comment('country', 'id', 'Identifiant interne auto-incremente.');
$this->comment('country', 'code', 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.');
$this->comment('country', 'name', 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).');
$this->comment('country', 'position', 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).');
// Seed initial. France en tete (position 10) puis ordre alphabetique.
// Table fraichement creee, mais ON CONFLICT pour rejouabilite en prod.
$this->addSql(<<<'SQL'
INSERT INTO country (code, name, position) VALUES
('FR', 'France', 10),
('DE', 'Allemagne', 20),
('BE', 'Belgique', 30),
('ES', 'Espagne', 40),
('IT', 'Italie', 50),
('GB', 'Royaume-Uni', 60)
ON CONFLICT (code) DO NOTHING
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE country');
}
/**
* Pose un `COMMENT ON TABLE` (colonne speciale `_table`) ou
* `COMMENT ON COLUMN`. Quoting defensif des identifiants + delimiteur $_$
* pour ne pas casser sur les apostrophes des 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,
));
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Commercial\Infrastructure\Doctrine\DoctrineCountryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Pays selectionnable dans les adresses (clients / fournisseurs) : referentiel
* statique seede par la migration (France, Allemagne, Belgique, Espagne, Italie,
* Royaume-Uni). Remplace la liste de pays jusqu'ici codee en dur cote front.
*
* Perimetre minimal (ticket ERP-116, 1re iteration) : code ISO + libelle + ordre
* d'affichage uniquement. AUCUNE longueur bancaire/fiscale (numero de compte,
* IBAN, TVA, BIC, SIREN) a ce stade — ces colonnes feront l'objet d'une iteration
* ulterieure du meme ticket.
*
* Lecture seule : GetCollection + Get uniquement ; POST/PATCH/DELETE -> 405.
* Permission alignee sur Bank (referentiel d'adresse partage clients/fournisseurs).
* Pas de Timestampable/Blamable (referentiel statique whiteliste dans
* EntitiesAreTimestampableBlamableTest::EXCLUDED, comme Bank).
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
// Tri par defaut : position ASC (France en tete) puis name ASC.
order: ['position' => 'ASC', 'name' => 'ASC'],
// Toggle ?pagination=false pour alimenter le select (cf. Bank).
paginationClientEnabled: true,
),
new Get(
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
normalizationContext: ['groups' => ['country:read']],
),
],
security: "is_granted('commercial.clients.view') or is_granted('commercial.suppliers.view')",
)]
#[ORM\Entity(repositoryClass: DoctrineCountryRepository::class)]
#[ORM\Table(name: 'country')]
#[ORM\UniqueConstraint(name: 'uq_country_code', columns: ['code'])]
class Country
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['country:read'])]
private ?int $id = null;
#[ORM\Column(length: 2)]
#[Groups(['country:read'])]
private ?string $code = null;
#[ORM\Column(length: 80)]
#[Groups(['country:read'])]
private ?string $name = null;
#[ORM\Column(options: ['default' => 0])]
#[Groups(['country:read'])]
private int $position = 0;
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Domain\Repository;
use App\Module\Commercial\Domain\Entity\Country;
interface CountryRepositoryInterface
{
public function findById(int $id): ?Country;
/**
* Retourne tous les pays tries position ASC puis name ASC.
*
* @return list<Country>
*/
public function findAllOrdered(): array;
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\DataFixtures;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -14,10 +15,11 @@ use Doctrine\Persistence\ObjectManager;
/**
* Fixtures du module Commercial : re-seed des 4 referentiels comptables
* (tva_mode, payment_delay, payment_type, bank) seedes par la migration M1
* (Version20260601000000).
* (Version20260601000000) + du referentiel pays (country) seede par la
* migration ERP-116 (Version20260609100000).
*
* Pourquoi cette fixture EN PLUS du seed de la migration : depuis ERP-54 ces
* 4 tables sont des entites managees par l'ORM, donc le purger Doctrine les
* Pourquoi cette fixture EN PLUS du seed de la migration : ces tables sont des
* entites managees par l'ORM, donc le purger Doctrine les
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les
* referentiels seedes par la migration disparaitraient apres `make db-reset`
* (0 ligne en dev/test) — cassant les FK Client -> referentiels et les tests
@@ -59,15 +61,53 @@ class CommercialReferentialFixtures extends Fixture
],
];
/**
* Referentiel pays (ERP-116) : code ISO alpha-2 => [name, position].
* Doit rester aligne sur le seed de la migration Version20260609100000.
* Traite a part car Country porte `name` (et non `label`).
*
* @var array<string, array{string, int}>
*/
private const COUNTRIES = [
'FR' => ['France', 10],
'DE' => ['Allemagne', 20],
'BE' => ['Belgique', 30],
'ES' => ['Espagne', 40],
'IT' => ['Italie', 50],
'GB' => ['Royaume-Uni', 60],
];
public function load(ObjectManager $manager): void
{
foreach (self::REFERENTIALS as $entityClass => $rows) {
$this->seedReferential($manager, $entityClass, $rows);
}
$this->seedCountries($manager);
$manager->flush();
}
/**
* Upsert idempotent du referentiel pays (lookup par code). Distinct de
* seedReferential car Country utilise setName au lieu de setLabel.
*/
private function seedCountries(ObjectManager $manager): void
{
$existingByCode = [];
foreach ($manager->getRepository(Country::class)->findAll() as $country) {
$existingByCode[$country->getCode()] = $country;
}
foreach (self::COUNTRIES as $code => [$name, $position]) {
$country = $existingByCode[$code] ?? new Country();
$country->setCode($code);
$country->setName($name);
$country->setPosition($position);
$manager->persist($country);
}
}
/**
* Upsert idempotent d'un referentiel : indexe l'existant par code puis
* cree/met a jour chaque entree. Les 4 entites partagent le meme contrat
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial\Infrastructure\Doctrine;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Repository\CountryRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Country>
*/
class DoctrineCountryRepository extends ServiceEntityRepository implements CountryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Country::class);
}
public function findById(int $id): ?Country
{
return $this->find($id);
}
public function findAllOrdered(): array
{
return $this->createQueryBuilder('c')
->orderBy('c.position', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult()
;
}
}
@@ -170,6 +170,14 @@ final class ColumnCommentsCatalog
'position' => 'Ordre d affichage croissant dans les selecteurs (tri position ASC puis label ASC).',
],
'country' => [
'_table' => 'Referentiel des pays selectionnables dans les adresses (clients/fournisseurs). Perimetre minimal : code ISO + libelle + ordre (pas de longueurs bancaires/fiscales).',
'id' => 'Identifiant interne auto-incremente.',
'code' => 'Code pays ISO 3166-1 alpha-2 (2 lettres MAJUSCULES, ex: FR) — unique (uq_country_code), fige a la creation.',
'name' => 'Libelle FR du pays (≤ 80 caracteres) — valeur stockee telle quelle dans les adresses (country en chaine libre a ce stade).',
'position' => 'Ordre d affichage croissant dans le selecteur pays (tri position ASC puis name ASC ; France en tete).',
],
'client' => [
'_table' => 'Repertoire clients (M1 Commercial) — entites archivables (is_archived) et soft-deletables (deleted_at, HP M2).',
'id' => 'Identifiant interne auto-incremente.',
@@ -6,6 +6,7 @@ namespace App\Tests\Architecture;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\Country;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
@@ -58,6 +59,8 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
* tracabilite user-driven, meme justification que CategoryType. Cf.
* spec-back M1 § 2.6 + § 3.5.
* - Country (ERP-116) : referentiel statique des pays (id/code/name/position),
* seede par migration, lecture seule. Meme justification que Bank.
*
* Les futurs referentiels statiques s'ajoutent ici avec une justification.
*/
@@ -71,6 +74,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
PaymentDelay::class,
PaymentType::class,
Bank::class,
Country::class,
];
public function testAllBusinessEntitiesImplementBothInterfaces(): void
@@ -241,6 +241,72 @@ final class ReferentialApiTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(401);
}
/**
* Referentiel pays (ERP-116) — teste a part des 4 referentiels comptables
* car il expose `name` (et non `label`). Memes garanties : 200 + seed des 6
* pays, France en tete (position ASC), lecture seule (405), gating (403/401).
*/
public function testCountriesCollectionReturns200WithSeed(): void
{
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$members = $response->toArray()['member'];
$codes = array_map(static fn (array $m): string => $m['code'], $members);
foreach (['FR', 'DE', 'BE', 'ES', 'IT', 'GB'] as $expected) {
self::assertContains($expected, $codes, '/api/countries doit exposer le pays seede '.$expected);
}
// Le DTO de lecture expose id / code / name / position.
$first = $members[0];
self::assertArrayHasKey('id', $first);
self::assertArrayHasKey('name', $first);
self::assertArrayHasKey('position', $first);
// Tri par defaut position ASC : France (position 10) en tete.
self::assertSame('FR', $first['code'], 'France (FR) doit etre en tete (position 10, tri position ASC).');
}
public function testCountriesGetItemReturns200(): void
{
$client = $this->createAdminClient();
$first = $client->request('GET', '/api/countries?pagination=false', ['headers' => ['Accept' => self::LD]])
->toArray()['member'][0]
;
$client->request('GET', '/api/countries/'.$first['id'], ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
}
public function testCountriesPostReturns405(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/countries', [
'headers' => ['Content-Type' => self::LD],
'json' => ['code' => 'XX', 'name' => 'Pays X', 'position' => 1],
]);
self::assertResponseStatusCodeSame(405);
}
public function testCountriesForbiddenWithoutPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
}
public function testCountriesUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', '/api/countries', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
/**
* @return iterable<string, array{string, list<string>}>
*/