feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116)
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+43
-3
@@ -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>}>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user