[ERP-78] Refonte taxonomie Catégories : type unique CLIENT + Category.code + RG-1.03/1.29 par code #42
@@ -208,6 +208,8 @@ migration-migrate:
|
||||
# exprimables via les attributs Doctrine ORM (fonctionnel + partiel), donc
|
||||
# ils disparaissent apres schema:update. On les recree par dbal:run-sql :
|
||||
# - `uq_category_name_type_active` (M0 Catalog) : tests RG-1.07.
|
||||
# - `uq_category_code` (Catalog ERP-78) : unicite du code categorie parmi
|
||||
# les actifs (slug du nom), pilote RG-1.03/1.29.
|
||||
# - `uq_client_company_name_active` (M1 Commercial) : unicite nom societe
|
||||
# parmi actifs non archives/non supprimes (RG-1.16), tests ERP-55.
|
||||
# Sans ces restores, les POST doublons remontent 201 au lieu de 409.
|
||||
@@ -225,6 +227,7 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:sync-permissions
|
||||
$(SYMFONY_CONSOLE) --env=test --no-interaction app:seed-rbac
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-78 — Refonte de la taxonomie Categories (M0/M1).
|
||||
*
|
||||
* Modele AVANT (merge via Version20260527164000 + Version20260601000000) :
|
||||
* DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE sont des `category_type`.
|
||||
*
|
||||
* Modele APRES (decision produit 01/06) :
|
||||
* - UN SEUL `category_type` : CLIENT (code CLIENT, label « Client ») ;
|
||||
* - Distributeur / Courtier / Secteur / Autre (+ categories metier fines)
|
||||
* deviennent des `Category` rattachees au type CLIENT ;
|
||||
* - filtrage metier sur un `code` stable porte par la `Category` (et non plus
|
||||
* par le type) : on reporte les codes DISTRIBUTEUR / COURTIER sur la categorie
|
||||
* correspondante. RG-1.03 (distributor/broker) et RG-1.29 (categorie interdite
|
||||
* sur adresse) s'appuient desormais sur `category.code`.
|
||||
*
|
||||
* Migration CORRECTIVE et NOUVELLE : la migration mergee Version20260601000000
|
||||
* (qui a pu tourner en CI / chez d'autres devs) n'est PAS editee.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire
|
||||
* Catalog : avec plusieurs migrations_paths, Doctrine Migrations 3.x trie par
|
||||
* FQCN alphabetique (AlphabeticalComparator). Introduire la 1re migration
|
||||
* modulaire `App\Module\Catalog\...` la ferait trier AVANT toutes les
|
||||
* `DoctrineMigrations\...` sur base vide -> elle s'executerait avant la creation
|
||||
* des tables et le seed dont elle depend. Le namespace racine garantit l'ordre
|
||||
* par timestamp.
|
||||
*
|
||||
* Idempotence : `ADD COLUMN IF NOT EXISTS`, `INSERT ... ON CONFLICT` / guards
|
||||
* `NOT EXISTS`, `CREATE UNIQUE INDEX IF NOT EXISTS`. En prod la table `category`
|
||||
* est vide (aucune fixture metier) : l'ajout de `code NOT NULL` est sur. En
|
||||
* dev/test, le purger Doctrine vide `category`/`category_type` avant les
|
||||
* fixtures, qui reproduisent le meme etat final (cf. CategoryTypeFixtures /
|
||||
* CategoryFixtures).
|
||||
*/
|
||||
final class Version20260602100000 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Categories systeme reportees depuis les anciens types : nom => code.
|
||||
* Le code est la cle metier stable (RG-1.03 / RG-1.29).
|
||||
*/
|
||||
private const array SYSTEM_CATEGORIES = [
|
||||
'Distributeur' => 'DISTRIBUTEUR',
|
||||
'Courtier' => 'COURTIER',
|
||||
'Secteur' => 'SECTEUR',
|
||||
'Autre' => 'AUTRE',
|
||||
];
|
||||
|
||||
/** Anciens codes de `category_type` devenus inutiles. */
|
||||
private const array LEGACY_TYPE_CODES = ['DISTRIBUTEUR', 'COURTIER', 'SECTEUR', 'AUTRE'];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-78 : Category.code + type unique CLIENT (categories Distributeur/Courtier/Secteur/Autre codees, anciens types supprimes).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Colonne `code` (nullable d'abord pour pouvoir backfiller, NOT NULL ensuite).
|
||||
$this->addSql('ALTER TABLE category ADD COLUMN IF NOT EXISTS code VARCHAR(50) DEFAULT NULL');
|
||||
|
||||
// 2. Type unique CLIENT (idempotent via l'index unique uq_category_type_code).
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES ('CLIENT', 'Client')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
|
||||
// 3. Re-pointer toute categorie pre-existante (rattachee a un ancien type)
|
||||
// vers le type CLIENT, en lui donnant un code derive du nom si absent.
|
||||
// En prod la table est vide -> no-op ; defensif pour les envs qui
|
||||
// auraient deja seede des categories sous les anciens types.
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE category c
|
||||
SET category_type_id = (SELECT id FROM category_type WHERE code = 'CLIENT'),
|
||||
code = COALESCE(
|
||||
c.code,
|
||||
LEFT(UPPER(REGEXP_REPLACE(c.name, '[^A-Za-z0-9]+', '_', 'g')), 50)
|
||||
)
|
||||
WHERE c.category_type_id IN (
|
||||
SELECT id FROM category_type WHERE code IN (:legacyCodes)
|
||||
)
|
||||
SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]);
|
||||
|
||||
// 4. Creer les 4 categories systeme sous CLIENT (si leur 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).
|
||||
foreach (self::SYSTEM_CATEGORIES as $name => $code) {
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category (name, code, category_type_id, created_at, updated_at)
|
||||
SELECT :name, :code, ct.id, NOW(), NOW()
|
||||
FROM category_type ct
|
||||
WHERE ct.code = 'CLIENT'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.code = :code AND c.deleted_at IS NULL
|
||||
)
|
||||
SQL, ['name' => $name, 'code' => $code]);
|
||||
}
|
||||
|
||||
// 5. Backfill defensif : toute categorie encore sans code recoit un slug
|
||||
// de son nom (garantit que le SET NOT NULL passe).
|
||||
$this->addSql(<<<'SQL'
|
||||
UPDATE category
|
||||
SET code = LEFT(UPPER(REGEXP_REPLACE(name, '[^A-Za-z0-9]+', '_', 'g')), 50)
|
||||
WHERE code IS NULL
|
||||
SQL);
|
||||
|
||||
// 6. Index unique partiel sur le code parmi les actifs (non exprimable en
|
||||
// ORM : recree aussi dans `test-db-setup` apres schema:update).
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL');
|
||||
|
||||
// 7. Code desormais obligatoire.
|
||||
$this->addSql('ALTER TABLE category ALTER COLUMN code SET NOT NULL');
|
||||
|
||||
// 8. Documentation SQL (regle ABSOLUE n°12). Dollar-quoting Postgres.
|
||||
$this->addSql(<<<'SQL'
|
||||
COMMENT ON COLUMN category.code IS $_$Code technique stable (slug MAJUSCULE du nom, <= 50) — unique parmi les actifs (uq_category_code). Fige a la creation. DISTRIBUTEUR/COURTIER pilotent RG-1.03/1.29.$_$
|
||||
SQL);
|
||||
|
||||
// 9. Supprimer les anciens types devenus orphelins (aucune categorie ne
|
||||
// les reference plus apres le re-pointage de l'etape 3). Le guard
|
||||
// NOT EXISTS evite de casser sur la FK RESTRICT category.category_type_id.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type
|
||||
WHERE code IN (:legacyCodes)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||
)
|
||||
SQL, ['legacyCodes' => self::LEGACY_TYPE_CODES], ['legacyCodes' => \Doctrine\DBAL\ArrayParameterType::STRING]);
|
||||
|
||||
// 10. Realigner la doc SQL de client_address_category (migration mergee
|
||||
// Version20260601000000, non editable) sur le nouveau modele RG-1.29.
|
||||
$this->addSql(<<<'SQL'
|
||||
COMMENT ON TABLE client_address_category IS $_$Jointure M2M client_address <-> category — codes DISTRIBUTEUR/COURTIER interdits sur une adresse (RG-1.29).$_$
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
COMMENT ON COLUMN client_address_category.category_id IS $_$FK -> category.id, ON DELETE RESTRICT — categorie d adresse (tout code sauf DISTRIBUTEUR/COURTIER, RG-1.29).$_$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Best-effort : rollback du modele CLIENT vers les 4 anciens types.
|
||||
// 1. Retirer l'index unique sur le code.
|
||||
$this->addSql('DROP INDEX IF EXISTS uq_category_code');
|
||||
|
||||
// 2. Recreer les 4 anciens types.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES
|
||||
('DISTRIBUTEUR', 'Distributeur'),
|
||||
('COURTIER', 'Courtier'),
|
||||
('SECTEUR', 'Secteur'),
|
||||
('AUTRE', 'Autre')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
|
||||
// 3. Re-pointer les categories systeme (par code) vers leur type d'origine.
|
||||
// Codes inlines : constantes controlees (self::SYSTEM_CATEGORIES), pas
|
||||
// d'entree utilisateur — evite le binding d'un parametre nomme repete.
|
||||
foreach (self::SYSTEM_CATEGORIES as $name => $code) {
|
||||
$this->addSql(sprintf(
|
||||
"UPDATE category SET category_type_id = (SELECT id FROM category_type WHERE code = '%s') WHERE code = '%s'",
|
||||
$code,
|
||||
$code,
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Supprimer le type CLIENT s'il ne reference plus aucune categorie.
|
||||
$this->addSql(<<<'SQL'
|
||||
DELETE FROM category_type
|
||||
WHERE code = 'CLIENT'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM category c WHERE c.category_type_id = category_type.id
|
||||
)
|
||||
SQL);
|
||||
|
||||
// 5. Retirer la colonne code (les categories libres sans type d'origine
|
||||
// restent sous CLIENT si encore presentes — rollback uniquement
|
||||
// pertinent en prod ou seules les 4 categories systeme existent).
|
||||
$this->addSql('ALTER TABLE category DROP COLUMN IF EXISTS code');
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,19 @@ use RuntimeException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Fixtures dev/test du module Catalog : ~12 categories de demonstration reparties
|
||||
* sur les 4 types metier (DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE). Alimente le
|
||||
* repertoire clients (ClientFixtures, module Commercial) avec des donnees
|
||||
* realistes couvrant les categorisations RG-1.03 (DISTRIBUTEUR/COURTIER) et
|
||||
* RG-1.29 (SECTEUR/AUTRE sur adresse).
|
||||
* Fixtures dev/test du module Catalog : ~11 categories de demonstration, toutes
|
||||
* rattachees au type unique CLIENT (refonte taxonomie ERP-78). 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).
|
||||
*
|
||||
* Depend de CategoryTypeFixtures : les 4 CategoryType doivent etre seedes avant
|
||||
* de pouvoir y rattacher des Category.
|
||||
* Depend de CategoryTypeFixtures : le type CLIENT doit etre seede avant de
|
||||
* pouvoir y rattacher des Category.
|
||||
*
|
||||
* Idempotence : lookup par (name, categoryType) parmi les categories non
|
||||
* supprimees (deletedAt null), coherent avec l'index unique partiel
|
||||
* uq_category_name_type_active (LOWER(name), category_type_id WHERE deleted_at
|
||||
* IS NULL). Rejouable sans doublon meme si le purger Doctrine est desactive.
|
||||
* Idempotence : lookup par `code` parmi les categories non supprimees (deletedAt
|
||||
* null), coherent avec l'index unique partiel uq_category_code (code WHERE
|
||||
* deleted_at IS NULL). Rejouable sans doublon meme si le purger Doctrine est
|
||||
* desactive.
|
||||
*
|
||||
* Audit / Blamable : persist hors contexte HTTP -> created_by / updated_by
|
||||
* restent null (« Systeme » cote front), c'est attendu.
|
||||
@@ -34,39 +34,33 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* Portee : DONNEES DE DEMONSTRATION (dev uniquement). En environnement `test`,
|
||||
* la fixture ne charge rien : les tests seedent et nettoient leurs propres
|
||||
* categories (prefixe dedie) et comptent sur une table `category` vierge — y
|
||||
* injecter 12 categories de demo casserait comptages et cleanups FK
|
||||
* injecter des categories de demo casserait comptages et cleanups FK
|
||||
* (client_category). Cf. ClientFixtures (meme garde-fou).
|
||||
*/
|
||||
class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/** Code du type unique (cf. CategoryTypeFixtures, migration ERP-78). */
|
||||
private const string CLIENT_TYPE_CODE = 'CLIENT';
|
||||
|
||||
/**
|
||||
* Source unique des categories de demonstration : code de type metier =>
|
||||
* liste de noms. Les noms sont stockes tels quels (l'unicite est
|
||||
* case-insensitive cote index).
|
||||
* Source unique des categories de demonstration : nom => code stable. Les 4
|
||||
* premieres (Distributeur / Courtier / Secteur / Autre) sont les categories
|
||||
* « systeme » reportees des anciens types ; leurs codes pilotent les RG.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const CATEGORIES = [
|
||||
'SECTEUR' => [
|
||||
'BTP',
|
||||
'Industrie',
|
||||
'Agro-alimentaire',
|
||||
'Transport/Logistique',
|
||||
'Services',
|
||||
],
|
||||
'DISTRIBUTEUR' => [
|
||||
'Distributeur Grand Sud-Ouest',
|
||||
'Distributeur National Premium',
|
||||
'Grossiste régional',
|
||||
],
|
||||
'COURTIER' => [
|
||||
'Cabinet de courtage Léonard',
|
||||
'Cabinet de courtage Bernard',
|
||||
],
|
||||
'AUTRE' => [
|
||||
'Indépendant',
|
||||
'Association',
|
||||
],
|
||||
'Distributeur' => 'DISTRIBUTEUR',
|
||||
'Courtier' => 'COURTIER',
|
||||
'Secteur' => 'SECTEUR',
|
||||
'Autre' => 'AUTRE',
|
||||
'BTP' => 'BTP',
|
||||
'Industrie' => 'INDUSTRIE',
|
||||
'Agro-alimentaire' => 'AGRO_ALIMENTAIRE',
|
||||
'Transport/Logistique' => 'TRANSPORT_LOGISTIQUE',
|
||||
'Services' => 'SERVICES',
|
||||
'Association' => 'ASSOCIATION',
|
||||
'Indépendant' => 'INDEPENDANT',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -90,40 +84,38 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// Index des types metier par code (CategoryTypeFixtures les a seedes).
|
||||
$typesByCode = [];
|
||||
$clientType = null;
|
||||
foreach ($this->categoryTypeRepository->findAllOrderedByLabel() as $type) {
|
||||
$typesByCode[$type->getCode()] = $type;
|
||||
if (self::CLIENT_TYPE_CODE === $type->getCode()) {
|
||||
$clientType = $type;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::CATEGORIES as $typeCode => $names) {
|
||||
$type = $typesByCode[$typeCode] ?? null;
|
||||
if (!$type instanceof CategoryType) {
|
||||
if (!$clientType instanceof CategoryType) {
|
||||
// Misconfiguration : CategoryTypeFixtures n'a pas tourne avant.
|
||||
throw new RuntimeException(sprintf(
|
||||
'CategoryTypeFixtures doit avoir seede le type "%s" avant CategoryFixtures.',
|
||||
$typeCode,
|
||||
));
|
||||
throw new RuntimeException(
|
||||
'CategoryTypeFixtures doit avoir seede le type "CLIENT" avant CategoryFixtures.',
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($names as $name) {
|
||||
$this->ensureCategory($manager, $name, $type);
|
||||
}
|
||||
foreach (self::CATEGORIES as $name => $code) {
|
||||
$this->ensureCategory($manager, $name, $code, $clientType);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree la categorie (name, type) si elle n'existe pas encore parmi les
|
||||
* categories actives, sinon la laisse en place. Lookup aligne sur l'index
|
||||
* unique partiel (nom + type, hors soft-deleted).
|
||||
* Cree la categorie (name, code) sous le type CLIENT si son code n'existe pas
|
||||
* encore parmi les categories actives, sinon la laisse en place. Lookup
|
||||
* aligne sur l'index unique partiel uq_category_code.
|
||||
*/
|
||||
private function ensureCategory(ObjectManager $manager, string $name, CategoryType $type): void
|
||||
private function ensureCategory(ObjectManager $manager, string $name, string $code, CategoryType $type): void
|
||||
{
|
||||
$existing = $manager->getRepository(Category::class)->findOneBy([
|
||||
'name' => $name,
|
||||
'categoryType' => $type,
|
||||
'code' => $code,
|
||||
'deletedAt' => null,
|
||||
]);
|
||||
|
||||
@@ -133,6 +125,7 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
$category = new Category();
|
||||
$category->setName($name);
|
||||
$category->setCode($code);
|
||||
$category->setCategoryType($type);
|
||||
$manager->persist($category);
|
||||
}
|
||||
|
||||
@@ -10,17 +10,19 @@ use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Catalog : seed des types de categorie metier (M1).
|
||||
* Fixtures du module Catalog : seed du type de categorie (M1).
|
||||
*
|
||||
* La table `category_type` est creee vide au M0 ; le M1 la peuple avec les 4
|
||||
* types DISTRIBUTEUR / COURTIER / SECTEUR / AUTRE (cf. spec M1 § 3.3).
|
||||
* Refonte taxonomie ERP-78 : le modele n'a plus qu'UN SEUL `category_type`,
|
||||
* CLIENT (code CLIENT, label « Client »). Distributeur / Courtier / Secteur /
|
||||
* Autre (et les categories metier fines) sont desormais des `Category` codees
|
||||
* rattachees a ce type (cf. CategoryFixtures + migration Version20260602100000).
|
||||
*
|
||||
* Pourquoi une fixture EN PLUS du seed de la migration (Version20260601000000) :
|
||||
* `category_type` est une entite managee par l ORM, donc le purger Doctrine la
|
||||
* vide avant chaque `doctrine:fixtures:load`. Sans cette fixture, les 4 types
|
||||
* seedes par la migration disparaitraient apres `make db-reset` / setup de test.
|
||||
* Le seed migration couvre la prod (ou les fixtures ne tournent pas) ; cette
|
||||
* fixture re-aligne dev et test. Les deux chemins produisent un etat identique.
|
||||
* 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
|
||||
* migration disparaitrait apres `make db-reset` / setup de test. Le seed
|
||||
* migration couvre la prod (ou les fixtures ne tournent pas) ; cette fixture
|
||||
* re-aligne dev et test. Les deux chemins produisent un etat identique.
|
||||
*
|
||||
* Idempotence : lookup par `code` parmi les types existants avant insertion,
|
||||
* sur le modele d AppFixtures::ensureSystemRole. Rejouable sans doublon meme
|
||||
@@ -29,14 +31,11 @@ use Doctrine\Persistence\ObjectManager;
|
||||
class CategoryTypeFixtures extends Fixture
|
||||
{
|
||||
/**
|
||||
* Source unique des 4 types metier : code technique => libelle FR.
|
||||
* Doit rester aligne sur le seed de la migration Version20260601000000.
|
||||
* Source unique du type : code technique => libelle FR. Doit rester aligne
|
||||
* sur le seed de la migration Version20260602100000 (type unique CLIENT).
|
||||
*/
|
||||
private const TYPES = [
|
||||
'DISTRIBUTEUR' => 'Distributeur',
|
||||
'COURTIER' => 'Courtier',
|
||||
'SECTEUR' => 'Secteur',
|
||||
'AUTRE' => 'Autre',
|
||||
'CLIENT' => 'Client',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
Reference in New Issue
Block a user