Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdd4394e99 | |||
| 8085f30077 |
@@ -134,16 +134,6 @@ return [
|
||||
'module' => 'transport',
|
||||
'permission' => 'transport.carriers.view',
|
||||
],
|
||||
// Catalogue produit (M6, ERP-197). Place juste sous le repertoire
|
||||
// transporteurs (DECISION Matthieu 24/06). Admin-only : gate par
|
||||
// `catalog.products.view` et son module owner `catalog`.
|
||||
[
|
||||
'label' => 'sidebar.catalog.products',
|
||||
'to' => '/admin/products',
|
||||
'icon' => 'mdi:package-variant-closed',
|
||||
'module' => 'catalog',
|
||||
'permission' => 'catalog.products.view',
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.core.roles',
|
||||
'to' => '/admin/roles',
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.151'
|
||||
app.version: '0.1.152'
|
||||
|
||||
@@ -52,8 +52,7 @@
|
||||
"admin": "Sites"
|
||||
},
|
||||
"catalog": {
|
||||
"categories": "Gestion des catégories",
|
||||
"products": "Catalogue produit"
|
||||
"categories": "Gestion des catégories"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -817,7 +816,6 @@
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"catalog_product": "Produit",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface Persona {
|
||||
// sidebar-visibility pour driver la matrice. Les valeurs correspondent
|
||||
// aux slugs de route (`/admin/<slug>`), volontairement stables quand
|
||||
// la copie/i18n change.
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'>
|
||||
expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories'>
|
||||
}
|
||||
|
||||
const SHARED_PASSWORD = 'e2e-secret'
|
||||
@@ -47,7 +47,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
password: SHARED_PASSWORD,
|
||||
isAdmin: true,
|
||||
permissions: [],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
'user-full': {
|
||||
key: 'user-full',
|
||||
@@ -65,12 +65,6 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx p.3) :
|
||||
// mappe sur le persona "tout", pas de nouveau persona (regle ABSOLUE
|
||||
// n°7). L'item vit dans la section Administration sur la route
|
||||
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
|
||||
'catalog.products.view',
|
||||
'catalog.products.manage',
|
||||
// Commercial — Repertoire clients (M1). Mappe ici sur le persona
|
||||
// "tout" en attendant les vrais roles metier (bureau/compta/
|
||||
// commerciale/usine) seedes par ERP-74. Pas de nouveau persona
|
||||
@@ -116,7 +110,7 @@ export const personas: Record<PersonaKey, Persona> = {
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'],
|
||||
expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'audit-log'],
|
||||
},
|
||||
'user-readonly': {
|
||||
key: 'user-readonly',
|
||||
@@ -161,4 +155,4 @@ export function getPersona(key: PersonaKey): Persona {
|
||||
return personas[key]
|
||||
}
|
||||
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const
|
||||
export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'audit-log'] as const
|
||||
|
||||
@@ -18,6 +18,7 @@ COPY config config/
|
||||
COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
|
||||
@@ -233,7 +233,6 @@ test-db-setup:
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL"
|
||||
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL"
|
||||
|
||||
fixtures:
|
||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* M6 — Catalogue produit (ERP-198) : creation du schema BDD du module.
|
||||
*
|
||||
* Objets crees (spec-back § 3.2) :
|
||||
* - storage_type : referentiel PROVISOIRE des types de stockage (en attente de la
|
||||
* liste definitive d'Aurore — § 2.4 / RG-6.06). Lecture seule au M6.
|
||||
* - storage_type_site : jonction M2M storage_type <-> site (sur quels sites un type
|
||||
* de stockage est disponible — alimente le filtrage du multi-select par site).
|
||||
* - product : table principale (code unique global parmi les actifs, etats
|
||||
* multi-valeur JSONB, champs conditionnels SALE, categorie de type PRODUIT,
|
||||
* soft-delete prepare + Timestampable/Blamable).
|
||||
* - product_site : jonction M2M product <-> site (sites de disponibilite, RG-6.04).
|
||||
* - product_storage_type : jonction M2M product <-> storage_type (RG-6.06).
|
||||
*
|
||||
* Seed : ajout du `category_type` PRODUIT (miroir CategoryTypeFixtures, comme
|
||||
* CLIENT/FOURNISSEUR/PRESTATAIRE/ADRESSE — § 2.5). Les `Category` de type PRODUIT et
|
||||
* le seed Figma du referentiel storage_type suivent au ticket ERP-201.
|
||||
*
|
||||
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire :
|
||||
* la table product porte des FK cross-module (user, site, category). Le tri par
|
||||
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
|
||||
* tables sur base vide ; un namespace modulaire casserait `make db-reset` (cf.
|
||||
* Version20260617150000 pour le M5).
|
||||
*
|
||||
* Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`,
|
||||
* horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe
|
||||
* `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12).
|
||||
*
|
||||
* NB schema:update (test-db-setup) : product / storage_type et leurs jonctions seront
|
||||
* mappes en ORM au ticket suivant (entites Product + StorageType, ERP-199). D'ici la,
|
||||
* `schema:update --force` les drope sur la base de TEST uniquement (sans impact :
|
||||
* aucun test ne les reference encore, et dev/prod ne lancent jamais schema:update).
|
||||
* Leurs descriptions seront ajoutees a ColumnCommentsCatalog au ticket entites (comme
|
||||
* weighing_ticket : migration ERP-182, catalogue ERP-183).
|
||||
*/
|
||||
final class Version20260625110000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-198 (M6) : storage_type (+ jonction site) + product (+ jonctions site/stockage) + seed category_type PRODUIT.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->createStorageType();
|
||||
$this->createStorageTypeSite();
|
||||
$this->createProduct();
|
||||
$this->createProductSite();
|
||||
$this->createProductStorageType();
|
||||
$this->seedCategoryTypeProduit();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Ordre inverse des dependances FK.
|
||||
$this->addSql('DROP TABLE IF EXISTS product_storage_type');
|
||||
$this->addSql('DROP TABLE IF EXISTS product_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS product');
|
||||
$this->addSql('DROP TABLE IF EXISTS storage_type_site');
|
||||
$this->addSql('DROP TABLE IF EXISTS storage_type');
|
||||
// Retrait du type seede (best-effort : echoue si des categories le referencent
|
||||
// encore — attendu, le down sert au dev sur base saine).
|
||||
$this->addSql("DELETE FROM category_type WHERE code = 'PRODUIT'");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Referentiel des types de stockage (PROVISOIRE) — § 2.4 / RG-6.06
|
||||
// =================================================================
|
||||
|
||||
private function createStorageType(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE storage_type (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_storage_type_code ON storage_type (code)');
|
||||
|
||||
$this->comment('storage_type', '_table', 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.');
|
||||
$this->comment('storage_type', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('storage_type', 'code', 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).');
|
||||
$this->comment('storage_type', 'label', 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).');
|
||||
}
|
||||
|
||||
private function createStorageTypeSite(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE storage_type_site (
|
||||
storage_type_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (storage_type_id, site_id),
|
||||
CONSTRAINT fk_storage_type_site_type
|
||||
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_storage_type_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_storage_type_site_site ON storage_type_site (site_id)');
|
||||
|
||||
$this->comment('storage_type_site', '_table', 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).');
|
||||
$this->comment('storage_type_site', 'storage_type_id', 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.');
|
||||
$this->comment('storage_type_site', 'site_id', 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Table principale `product`
|
||||
// =================================================================
|
||||
|
||||
private function createProduct(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE product (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
states JSONB DEFAULT '[]'::jsonb NOT NULL,
|
||||
manufactured BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
contains_molasses BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
created_by INT DEFAULT NULL,
|
||||
updated_by INT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_product_states_not_empty
|
||||
CHECK (jsonb_array_length(states) >= 1),
|
||||
CONSTRAINT fk_product_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_product_created_by
|
||||
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_product_updated_by
|
||||
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Unicite GLOBALE du code parmi les actifs (soft-delete tolere) — index partiel.
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_product_code_active ON product (code) WHERE deleted_at IS NULL');
|
||||
$this->addSql('CREATE INDEX idx_product_category ON product (category_id)');
|
||||
$this->addSql('CREATE INDEX idx_product_deleted_at ON product (deleted_at)');
|
||||
$this->addSql('CREATE INDEX idx_product_created_by ON product (created_by)');
|
||||
$this->addSql('CREATE INDEX idx_product_updated_by ON product (updated_by)');
|
||||
|
||||
$this->comment('product', '_table', 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.');
|
||||
$this->comment('product', 'id', 'Identifiant interne auto-incremente.');
|
||||
$this->comment('product', 'code', 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).');
|
||||
$this->comment('product', 'name', 'Nom du produit (≤ 255). Normalise serveur (trim).');
|
||||
$this->comment('product', 'states', 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.');
|
||||
$this->comment('product', 'manufactured', '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
|
||||
$this->comment('product', 'contains_molasses', '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).');
|
||||
$this->comment('product', 'category_id', 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).');
|
||||
$this->comment('product', 'deleted_at', 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.');
|
||||
$this->addTimestampableBlamableComments('product');
|
||||
}
|
||||
|
||||
private function createProductSite(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE product_site (
|
||||
product_id INT NOT NULL,
|
||||
site_id INT NOT NULL,
|
||||
PRIMARY KEY (product_id, site_id),
|
||||
CONSTRAINT fk_product_site_product
|
||||
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_product_site_site
|
||||
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_product_site_site ON product_site (site_id)');
|
||||
|
||||
$this->comment('product_site', '_table', 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).');
|
||||
$this->comment('product_site', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
|
||||
$this->comment('product_site', 'site_id', 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.');
|
||||
}
|
||||
|
||||
private function createProductStorageType(): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE product_storage_type (
|
||||
product_id INT NOT NULL,
|
||||
storage_type_id INT NOT NULL,
|
||||
PRIMARY KEY (product_id, storage_type_id),
|
||||
CONSTRAINT fk_product_storage_type_product
|
||||
FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_product_storage_type_type
|
||||
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX idx_product_storage_type_type ON product_storage_type (storage_type_id)');
|
||||
|
||||
$this->comment('product_storage_type', '_table', 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).');
|
||||
$this->comment('product_storage_type', 'product_id', 'FK -> product.id, ON DELETE CASCADE — produit concerne.');
|
||||
$this->comment('product_storage_type', 'storage_type_id', 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.');
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Seed du type de categorie PRODUIT (§ 2.5) — miroir CategoryTypeFixtures
|
||||
// =================================================================
|
||||
|
||||
private function seedCategoryTypeProduit(): void
|
||||
{
|
||||
// Idempotent via l'index unique uq_category_type_code (comme CLIENT/FOURNISSEUR/
|
||||
// PRESTATAIRE/ADRESSE). Les Category de type PRODUIT suivent en ERP-201.
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO category_type (code, label) VALUES ('PRODUIT', 'Produit')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
SQL);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Helpers (identiques au M5 Version20260617150000)
|
||||
// =================================================================
|
||||
|
||||
/**
|
||||
* Pose les 4 commentaires standardises Timestampable/Blamable sur une table,
|
||||
* en reutilisant le catalogue partage (source unique, ERP-67).
|
||||
*/
|
||||
private function addTimestampableBlamableComments(string $table): void
|
||||
{
|
||||
foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) {
|
||||
$this->comment($table, $column, $description);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN`
|
||||
* en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe.
|
||||
*/
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Application\Service;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un Product, appliquee par le
|
||||
* ProductProcessor AVANT l'unicite du code et la persistance (RG-6.07, spec-back
|
||||
* M6 § 6). Jumeau du CarrierFieldNormalizer (M4), recentre sur les deux champs
|
||||
* texte du produit.
|
||||
*
|
||||
* - code : trim + UPPER (cohérent avec la stratégie de codes stables du Catalog —
|
||||
* le code produit fait office de cle metier saisie, unique global parmi les
|
||||
* actifs RG-6.01).
|
||||
* - name : trim simple (pas de changement de casse — libelle affiche).
|
||||
*
|
||||
* Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide apres
|
||||
* trim devient null. En pratique le ProductProcessor n'appelle ces methodes
|
||||
* qu'apres validation (NotBlank deja joue par API Platform), donc le code et le
|
||||
* name sont non vides a ce stade — le retour null reste un garde-fou.
|
||||
*/
|
||||
final class ProductFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Code produit en majuscules (RG-6.07) : " ble-01 " -> "BLE-01". Conserve
|
||||
* null tel quel ; une chaine vide apres trim devient null (c'est l'Assert\NotBlank
|
||||
* de l'entite qui rejette le vide, pas le normalizer).
|
||||
*/
|
||||
public function normalizeCode(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : mb_strtoupper($value, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom du produit trimme (RG-6.07), sans changement de casse. Une chaine vide
|
||||
* apres trim devient null.
|
||||
*/
|
||||
public function normalizeName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -43,10 +43,6 @@ final class CatalogModule
|
||||
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
|
||||
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
|
||||
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
|
||||
// Catalogue produit (M6, ERP-197) : admin-only (matrice docx p.3, C7).
|
||||
// Item sidebar dans la section Administration, sous « Repertoire transporteurs ».
|
||||
['code' => 'catalog.products.view', 'label' => 'Voir les produits'],
|
||||
['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor\ProductProcessor;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\ProductProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
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;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Produit du catalogue (M6 Catalog) — entite racine du module produit, jumelle de
|
||||
* Category (#[Auditable], TimestampableBlamable, soft-delete) cote pattern et de
|
||||
* Carrier (M4) / WeighingTicket (M5) cote contrat de serialisation (RETEX M1,
|
||||
* 3 maillons — spec § 4.0).
|
||||
*
|
||||
* Contrat de serialisation :
|
||||
* - LISTE (product:read + category:read + site:read + storage_type:read +
|
||||
* default:read) : code (« Numero »), name, states, manufactured,
|
||||
* containsMolasses, category embarquee, createdAt/updatedAt (via default:read).
|
||||
* - DETAIL (+ product:item:read) : ajoute sites + storageTypes embarques
|
||||
* (ensembles bornes -> embed autorise, ne viole pas la regle n°13). Le groupe
|
||||
* product:item:read est reserve pour d'eventuels champs detail-only ulterieurs.
|
||||
*
|
||||
* Regles de gestion (renvoyees au Processor/Provider, ERP-200) :
|
||||
* - RG-6.01 : `code` unique global parmi les actifs, normalise serveur (trim/UPPER),
|
||||
* 409 sur doublon (index partiel uq_product_code_active).
|
||||
* - RG-6.02 : `states` = sous-ensemble non vide de {PURCHASE, SALE, OTHER}.
|
||||
* - RG-6.03 : `manufactured` / `containsMolasses` saisis uniquement si states
|
||||
* contient SALE, sinon forces false serveur.
|
||||
* - RG-6.04 : `sites` >= 1.
|
||||
* - RG-6.05 : `category` de type PRODUIT (validee applicativement, Callback ERP-200).
|
||||
* - RG-6.06 : `storageTypes` >= 1, filtres par les sites selectionnes.
|
||||
*
|
||||
* Soft-delete prepare via `deletedAt` (non expose au M6, § 2.7) : pas de Delete
|
||||
* dans les operations, la liste exclut les produits supprimes (Provider, ERP-200).
|
||||
*
|
||||
* Les RG inter-champs (RG-6.03/6.05/6.06) et l'unicite du code passent par le
|
||||
* Processor + une contrainte d'entite Assert\Callback en ERP-200 (chaque 422
|
||||
* porte un propertyPath exploitable par useFormErrors — mapping inline, ERP-101).
|
||||
*
|
||||
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee
|
||||
* (§ 2.1) — on reutilise son read-group `site:read`, sans logique inter-module.
|
||||
* `Category` et `StorageType` sont dans le meme module Catalog.
|
||||
*
|
||||
* @see ProductProvider Lecture (liste paginee filtree soft-delete + item) — ERP-200.
|
||||
* @see ProductProcessor Ecriture (normalisation, unicite code, RG-6.03/05/06) — ERP-200.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
provider: ProductProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('catalog.products.manage')",
|
||||
normalizationContext: ['groups' => ['product:read', 'product:item:read', 'category:read', 'site:read', 'storage_type:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
provider: ProductProvider::class,
|
||||
processor: ProductProcessor::class,
|
||||
),
|
||||
// Pas de Delete au M6 (docx) ; soft-delete prepare non expose (§ 2.7).
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProductRepository::class)]
|
||||
#[ORM\Table(name: 'product')]
|
||||
// Index nommes pour matcher la migration (cf. Category). L'index unique partiel
|
||||
// `uq_product_code_active` (code WHERE deleted_at IS NULL — unicite GLOBALE du
|
||||
// code parmi les actifs, RG-6.01) reste possede par la seule migration :
|
||||
// Doctrine ORM ne sait pas exprimer un index partiel via attribut.
|
||||
#[ORM\Index(name: 'idx_product_category', columns: ['category_id'])]
|
||||
#[ORM\Index(name: 'idx_product_deleted_at', columns: ['deleted_at'])]
|
||||
#[ORM\Index(name: 'idx_product_created_by', columns: ['created_by'])]
|
||||
#[ORM\Index(name: 'idx_product_updated_by', columns: ['updated_by'])]
|
||||
#[Auditable]
|
||||
class Product implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
// === Timestampable + Blamable ===
|
||||
// Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs
|
||||
// getters/setters viennent du Trait Shared, remplies automatiquement par le
|
||||
// TimestampableBlamableSubscriber au prePersist / preUpdate.
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/** Etats du produit (RG-6.02) — valeurs autorisees de la colonne JSONB `states`. */
|
||||
public const string STATE_PURCHASE = 'PURCHASE';
|
||||
public const string STATE_SALE = 'SALE';
|
||||
public const string STATE_OTHER = 'OTHER';
|
||||
|
||||
/** Code de type de categorie autorise pour un produit (RG-6.05). */
|
||||
private const string PRODUCT_CATEGORY_TYPE_CODE = 'PRODUIT';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
// Code produit (= « Numero » de la liste), saisi, unique global parmi les
|
||||
// actifs (RG-6.01). Normalise serveur (trim/UPPER) par le ProductProcessor.
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank(message: 'Le code produit est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le code produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'Le nom du produit est obligatoire.', normalizer: 'trim')]
|
||||
#[Assert\Length(max: 255, maxMessage: 'Le nom du produit ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
/**
|
||||
* Etats du produit (multi-select), sous-ensemble non vide de
|
||||
* {PURCHASE, SALE, OTHER} (RG-6.02). Stocke en JSONB (tableau de chaines),
|
||||
* non-vacuite garantie aussi par le CHECK chk_product_states_not_empty.
|
||||
*
|
||||
* Validation des valeurs via Assert\Choice(multiple: true) plutot que
|
||||
* Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par
|
||||
* le garde-fou EntityConstraintsHaveFrenchMessageTest.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
|
||||
// migration (spec § 2.3 + CHECK chk_product_states_not_empty via
|
||||
// jsonb_array_length). Sans `options: ['jsonb' => true]`, schema:update tente
|
||||
// un ALTER states TYPE JSON qui casse le CHECK (jsonb_array_length(json) inconnu)
|
||||
// et fait echouer make db-reset / test-db-setup.
|
||||
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||
#[Assert\Choice(
|
||||
choices: [self::STATE_PURCHASE, self::STATE_SALE, self::STATE_OTHER],
|
||||
multiple: true,
|
||||
message: 'État de produit invalide.',
|
||||
multipleMessage: 'État de produit invalide.',
|
||||
)]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private array $states = [];
|
||||
|
||||
// « Fabrique » : saisi uniquement si states contient SALE, sinon force false
|
||||
// serveur (RG-6.03).
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $manufactured = false;
|
||||
|
||||
// « Contient de la melasse » : saisi uniquement si states contient SALE,
|
||||
// sinon force false serveur (RG-6.03).
|
||||
#[ORM\Column(name: 'contains_molasses', options: ['default' => false])]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private bool $containsMolasses = false;
|
||||
|
||||
// Categorie produit (obligatoire). Limitee aux categories de type PRODUIT,
|
||||
// validee applicativement (RG-6.05, Callback ERP-200). FK ON DELETE RESTRICT :
|
||||
// une categorie referencee par un produit ne peut etre supprimee.
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
#[Assert\NotNull(message: 'La catégorie produit est obligatoire.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?Category $category = null;
|
||||
|
||||
/**
|
||||
* Sites de disponibilite du produit (>= 1, RG-6.04). Relation ORM partagee
|
||||
* vers Site (module Sites, § 2.1). Cote inverse en ON DELETE RESTRICT : un
|
||||
* site reference par un produit ne peut etre supprime.
|
||||
*
|
||||
* @var Collection<int, Site>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'product_site')]
|
||||
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un site.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $sites;
|
||||
|
||||
/**
|
||||
* Types de stockage du produit (>= 1, RG-6.06), filtres par les sites
|
||||
* selectionnes (provider StorageType, ERP-201). Cote inverse en ON DELETE
|
||||
* RESTRICT : un type de stockage reference par un produit ne peut etre supprime.
|
||||
*
|
||||
* @var Collection<int, StorageType>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: StorageType::class)]
|
||||
#[ORM\JoinTable(name: 'product_storage_type')]
|
||||
#[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un type de stockage.')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private Collection $storageTypes;
|
||||
|
||||
/**
|
||||
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
|
||||
* Non expose au M6 (§ 2.7, aucun groupe) : prepare pour une future suppression
|
||||
* (HP-M6-04). La liste exclut par defaut les produits supprimes (Provider).
|
||||
*/
|
||||
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $deletedAt = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->storageTypes = new ArrayCollection();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getStates(): array
|
||||
{
|
||||
return $this->states;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $states
|
||||
*/
|
||||
public function setStates(array $states): static
|
||||
{
|
||||
$this->states = $states;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isManufactured(): bool
|
||||
{
|
||||
return $this->manufactured;
|
||||
}
|
||||
|
||||
public function setManufactured(bool $manufactured): static
|
||||
{
|
||||
$this->manufactured = $manufactured;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function containsMolasses(): bool
|
||||
{
|
||||
return $this->containsMolasses;
|
||||
}
|
||||
|
||||
public function setContainsMolasses(bool $containsMolasses): static
|
||||
{
|
||||
$this->containsMolasses = $containsMolasses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCategory(): ?Category
|
||||
{
|
||||
return $this->category;
|
||||
}
|
||||
|
||||
public function setCategory(?Category $category): static
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Site>
|
||||
*/
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(Site $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(Site $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StorageType>
|
||||
*/
|
||||
public function getStorageTypes(): Collection
|
||||
{
|
||||
return $this->storageTypes;
|
||||
}
|
||||
|
||||
public function addStorageType(StorageType $storageType): static
|
||||
{
|
||||
if (!$this->storageTypes->contains($storageType)) {
|
||||
$this->storageTypes->add($storageType);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeStorageType(StorageType $storageType): static
|
||||
{
|
||||
$this->storageTypes->removeElement($storageType);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeletedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.05 : la categorie d'un produit doit etre de type PRODUIT. Validee
|
||||
* applicativement (pas de contrainte SQL au referentiel, § 2.5) via Callback
|
||||
* + ->atPath('category') pour que la 422 porte un propertyPath consommable par
|
||||
* useFormErrors (mapping inline, ERP-101). Le NotNull gere l'absence : on ne
|
||||
* leve que si une categorie est presente ET non-PRODUIT.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryIsProductType(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (null === $this->category) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array(self::PRODUCT_CATEGORY_TYPE_CODE, $this->category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('La catégorie sélectionnée doit être de type Produit.')
|
||||
->atPath('category')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.06 : chaque type de stockage choisi doit etre disponible sur AU MOINS UN
|
||||
* des sites choisis (intersection non vide). Validee via Callback +
|
||||
* ->atPath('storageTypes'). On ne croise que si les deux collections sont non
|
||||
* vides : leur absence est deja couverte par les Assert\Count(min: 1) dedies.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateStorageTypesAvailableOnSelectedSites(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ($this->sites->isEmpty() || $this->storageTypes->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensemble des ids de sites selectionnes (lookup O(1)).
|
||||
$selectedSiteIds = [];
|
||||
foreach ($this->sites as $site) {
|
||||
$selectedSiteIds[$site->getId()] = true;
|
||||
}
|
||||
|
||||
foreach ($this->storageTypes as $storageType) {
|
||||
$available = false;
|
||||
foreach ($storageType->getSites() as $storageTypeSite) {
|
||||
if (isset($selectedSiteIds[$storageTypeSite->getId()])) {
|
||||
$available = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$available) {
|
||||
$context->buildViolation('Le type de stockage « {{ label }} » n\'est disponible sur aucun des sites sélectionnés.')
|
||||
->setParameter('{{ label }}', (string) $storageType->getLabel())
|
||||
->atPath('storageTypes')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageTypeProvider;
|
||||
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
/**
|
||||
* Type de stockage : referentiel PROVISOIRE classifiant ou un produit peut etre
|
||||
* stocke (ex: TAS, CELLULE, CUVE_MELASSE). Cree au M6 en attendant la liste et
|
||||
* le mapping site definitifs d'Aurore (HP-M6-02) ; seede avec la liste Figma
|
||||
* (node 1503-34285) au ticket ERP-201.
|
||||
*
|
||||
* Relation `sites` (ManyToMany -> Site) : sites sur lesquels ce type de stockage
|
||||
* est disponible. Sert au filtrage du multi-select « Type de stockage » par les
|
||||
* sites selectionnes dans le formulaire produit (RG-6.06). Non serialisee au M6
|
||||
* (le filtrage est applique cote provider en ERP-201).
|
||||
*
|
||||
* Lecture seule au M6 : seules les operations GetCollection et Get sont exposees
|
||||
* (CRUD admin = hors perimetre HP-M6-03), sous la permission `catalog.products.view`
|
||||
* (referentiel servant le formulaire produit — § 4.2).
|
||||
*
|
||||
* Referentiel statique : pas de Timestampable/Blamable ni `#[Auditable]`
|
||||
* (whiteliste dans EntitiesAreTimestampableBlamableTest::EXCLUDED, miroir
|
||||
* CategoryType — cree par migration/seed, pas pilote utilisateur). Le groupe
|
||||
* `storage_type:read` est porte par chaque propriete affichee pour que le type
|
||||
* soit embarque dans la reponse d'un Product (cf. .claude/rules/backend.md
|
||||
* § Serialization).
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// Tri label ASC et filtre ?siteId[]= portes par le StorageTypeProvider
|
||||
// (ERP-201) : alimente le multi-select « Type de stockage » du formulaire
|
||||
// produit, filtre par les sites selectionnes (RG-6.06). Pagination Hydra +
|
||||
// echappatoire ?pagination=false (referentiel borne).
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['storage_type:read']],
|
||||
provider: StorageTypeProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['storage_type:read']],
|
||||
provider: StorageTypeProvider::class,
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineStorageTypeRepository::class)]
|
||||
#[ORM\Table(name: 'storage_type')]
|
||||
// Contrainte d'unicite nommee pour matcher la migration (cf. CategoryType).
|
||||
#[ORM\UniqueConstraint(name: 'uq_storage_type_code', columns: ['code'])]
|
||||
class StorageType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['storage_type:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 40)]
|
||||
#[Groups(['storage_type:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['storage_type:read'])]
|
||||
private ?string $label = null;
|
||||
|
||||
/**
|
||||
* Sites sur lesquels ce type de stockage est disponible (RG-6.06). Non
|
||||
* exposee en serialisation au M6 : sert uniquement au filtrage `?siteId[]=`
|
||||
* du referentiel (branche en ERP-201).
|
||||
*
|
||||
* @var Collection<int, Site>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Site::class)]
|
||||
#[ORM\JoinTable(name: 'storage_type_site')]
|
||||
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
private Collection $sites;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
}
|
||||
|
||||
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 getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Site>
|
||||
*/
|
||||
public function getSites(): Collection
|
||||
{
|
||||
return $this->sites;
|
||||
}
|
||||
|
||||
public function addSite(Site $site): static
|
||||
{
|
||||
if (!$this->sites->contains($site)) {
|
||||
$this->sites->add($site);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSite(Site $site): static
|
||||
{
|
||||
$this->sites->removeElement($site);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface ProductRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Product;
|
||||
|
||||
public function save(Product $product): void;
|
||||
|
||||
/**
|
||||
* Vrai si un produit actif (deleted_at IS NULL) porte deja ce code.
|
||||
* `$excludeId` exclut un produit precis du test (cas PATCH). Garantit
|
||||
* l'unicite GLOBALE du code parmi les actifs (RG-6.01, index partiel
|
||||
* uq_product_code_active). Un code reutilisable apres soft-delete (le test
|
||||
* ignore les supprimes).
|
||||
*/
|
||||
public function existsActiveByCode(string $code, ?int $excludeId = null): bool;
|
||||
|
||||
/**
|
||||
* QueryBuilder de la liste produits (consomme par le ProductProvider) : exclut
|
||||
* par defaut les soft-deleted (RG-6.09), trie par name ASC (defaut spec § 4.1)
|
||||
* et applique les filtres optionnels du drawer « Filtrer » :
|
||||
* - `$search` : recherche partielle case-insensitive sur `code` + `name`.
|
||||
* - `$categoryId` : restreint a une categorie precise (par id).
|
||||
* - `$categoryCode` : restreint a une categorie precise (par code stable).
|
||||
* - `$state` : appartenance a la colonne JSONB `states` (PURCHASE|SALE|OTHER).
|
||||
* - `$siteIds` : produit disponible sur AU MOINS UN des sites passes.
|
||||
*
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $search = null,
|
||||
?int $categoryId = null,
|
||||
?string $categoryCode = null,
|
||||
?string $state = null,
|
||||
array $siteIds = [],
|
||||
): QueryBuilder;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface StorageTypeRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?StorageType;
|
||||
|
||||
/**
|
||||
* Tous les types de stockage tries par libelle (alimente le multi-select du
|
||||
* formulaire produit — § 4.2).
|
||||
*
|
||||
* @return list<StorageType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array;
|
||||
|
||||
/**
|
||||
* QueryBuilder de la liste des types de stockage (consomme par le
|
||||
* StorageTypeProvider) : tri `label ASC` (defaut spec § 4.2) et filtre
|
||||
* optionnel `?siteId[]=` (RG-6.06) restreignant aux types disponibles sur
|
||||
* AU MOINS UN des sites passes.
|
||||
*
|
||||
* @param list<int> $siteIds
|
||||
*/
|
||||
public function createListQueryBuilder(array $siteIds = []): QueryBuilder;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Catalog\Application\Service\ProductFieldNormalizer;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Throwable;
|
||||
|
||||
use function in_array;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du produit (M6, POST / PATCH). Cf. spec-back M6 § 4.3 /
|
||||
* § 4.4 + RG-6.01 / RG-6.03 / RG-6.07. Jumeau du CategoryProcessor (409 doublon)
|
||||
* et du CarrierProcessor (normalisation serveur).
|
||||
*
|
||||
* Sequence (POST / PATCH) :
|
||||
* 1. Normalisation serveur (RG-6.07) via ProductFieldNormalizer : code trim+UPPER,
|
||||
* name trim. Jouee AVANT l'unicite et la persistance ; la validation
|
||||
* (NotBlank/Length + Callback RG-6.05/6.06) a deja joue cote API Platform sur
|
||||
* la saisie brute.
|
||||
* 2. RG-6.03 : champs conditionnels SALE. Si `states` ne contient pas SALE,
|
||||
* `manufactured` et `containsMolasses` sont forces false serveur (ils ne sont
|
||||
* saisissables que si l'etat contient SALE).
|
||||
* 3. RG-6.01 : unicite GLOBALE du `code` parmi les actifs. Pre-check deterministe
|
||||
* (excluant le produit courant en PATCH) -> 409 ; l'index partiel
|
||||
* uq_product_code_active reste le filet anti-race au flush.
|
||||
* 4. Persistance via le persist_processor Doctrine ORM.
|
||||
*
|
||||
* Mode strict PATCH (RETEX M1) : la security d'operation exige deja
|
||||
* `catalog.products.manage` pour TOUS les champs ecrivables (un seul niveau de
|
||||
* permission au M6 — § 5.2 admin-only). Il n'existe donc aucun champ « hors-permission »
|
||||
* a re-gater finement (contrairement a l'archivage Carrier RG-4.14 ou au split
|
||||
* comptable Client RG-1.28) : le 403 global est porte par la security d'operation,
|
||||
* pas par un guard de champ ici.
|
||||
*
|
||||
* Les RG inter-champs RG-6.05 (categorie de type PRODUIT) et RG-6.06 (types de
|
||||
* stockage disponibles sur les sites choisis) sont portees par des Assert\Callback
|
||||
* + ->atPath() sur l'entite Product (jouees par API Platform AVANT ce processor),
|
||||
* pour que chaque 422 porte un propertyPath consommable par useFormErrors (mapping
|
||||
* inline, pas un toast — convention ERP-101).
|
||||
*
|
||||
* @implements ProcessorInterface<Product, Product>
|
||||
*/
|
||||
final class ProductProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly ProductFieldNormalizer $normalizer,
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||
private readonly ProductRepositoryInterface $repository,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof Product) {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// 1. RG-6.07 : normalisation serveur (code trim+UPPER, name trim).
|
||||
$this->normalize($data);
|
||||
|
||||
// 2. RG-6.03 : si l'etat ne contient pas SALE, les champs conditionnels
|
||||
// « Fabrique » / « Contient de la melasse » sont forces false serveur.
|
||||
if (!in_array(Product::STATE_SALE, $data->getStates(), true)) {
|
||||
$data->setManufactured(false);
|
||||
$data->setContainsMolasses(false);
|
||||
}
|
||||
|
||||
// 3. RG-6.01 : unicite GLOBALE du code parmi les actifs (exclut le produit
|
||||
// courant en PATCH). Pre-check explicite -> 409 deterministe.
|
||||
$code = (string) $data->getCode();
|
||||
if ('' !== $code && $this->repository->existsActiveByCode($code, $data->getId())) {
|
||||
throw $this->duplicateCodeConflict($code);
|
||||
}
|
||||
|
||||
// 4. Persistance, avec filet anti-race sur l'index partiel.
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Insertion concurrente du meme code entre le pre-check et le flush
|
||||
// (collision sur uq_product_code_active — unicite parmi les actifs).
|
||||
throw $this->duplicateCodeConflict($code, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisation serveur du produit (RG-6.07). Les setters ne sont touches que si
|
||||
* une valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH
|
||||
* partiel. Les casts (string) sont surs : NotBlank a deja rejete le vide en amont.
|
||||
*/
|
||||
private function normalize(Product $data): void
|
||||
{
|
||||
if (null !== $data->getCode()) {
|
||||
$data->setCode((string) $this->normalizer->normalizeCode($data->getCode()));
|
||||
}
|
||||
|
||||
if (null !== $data->getName()) {
|
||||
$data->setName((string) $this->normalizer->normalizeName($data->getName()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-6.01 : 409 sur doublon de code produit. Le front mappe ce conflit sur le
|
||||
* champ `code` (setError('code', ...) + toast — convention useFormErrors ERP-101
|
||||
* / useCategoryForm RG-1.07) : le propertyPath exploitable est `code`.
|
||||
*/
|
||||
private function duplicateCodeConflict(string $code, ?Throwable $previous = null): ConflictHttpException
|
||||
{
|
||||
return new ConflictHttpException(
|
||||
sprintf('Le code produit « %s » est déjà utilisé par un autre produit.', $code),
|
||||
$previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Provider Product (lecture, ERP-200) :
|
||||
* - LISTE : exclut par defaut les produits soft-deleted (RG-6.09), trie par
|
||||
* name ASC (defaut spec § 4.1), applique les filtres du drawer « Filtrer »
|
||||
* (?search, ?categoryId / ?categoryCode, ?state, ?siteId[]) et renvoie une
|
||||
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
|
||||
* operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM).
|
||||
* Echappatoire ?pagination=false respectee (alimentation d'un select).
|
||||
* - ITEM : recharge le produit puis renvoie null (404) s'il est soft-deleted —
|
||||
* le soft-delete n'est jamais expose au M6 (§ 2.7), aucun flag includeDeleted.
|
||||
*
|
||||
* @implements ProviderInterface<Product>
|
||||
*/
|
||||
final class ProductProvider implements ProviderInterface
|
||||
{
|
||||
/** Etats valides du filtre ?state= (enum borne, RG-6.02). */
|
||||
private const array VALID_STATES = [Product::STATE_PURCHASE, Product::STATE_SALE, Product::STATE_OTHER];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||
private readonly ProductRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Product|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
// includeDeleted toujours false : le soft-delete n'est pas expose au M6.
|
||||
$qb = $this->repository->createListQueryBuilder(
|
||||
false,
|
||||
$this->readSearch($context),
|
||||
$this->readCategoryId($context),
|
||||
$this->readCategoryCode($context),
|
||||
$this->readState($context),
|
||||
$this->readSiteIds($context),
|
||||
);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator.
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
// Branche paginee standard : offset/limit via Pagination, enveloppe dans
|
||||
// le Paginator ORM (fetchJoinCollection: true pour compter correctement
|
||||
// malgre les fetch-joins to-many sites/storageTypes du QueryBuilder).
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true));
|
||||
}
|
||||
|
||||
// Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete.
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$product = $this->repository->findById((int) $id);
|
||||
if (null === $product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// § 2.7 : un produit soft-deleted n'est jamais expose (404).
|
||||
if (null !== $product->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?search=` (recherche partielle code + name). Renvoie la valeur
|
||||
* trimmee ou null si absente / vide.
|
||||
*/
|
||||
private function readSearch(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['search'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?categoryId=` (drawer « Filtrer »). Renvoie l'id entier ou null
|
||||
* si absent / non numerique.
|
||||
*/
|
||||
private function readCategoryId(array $context): ?int
|
||||
{
|
||||
$raw = $context['filters']['categoryId'] ?? null;
|
||||
|
||||
if (is_int($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?categoryCode=` (drawer « Filtrer »). Renvoie le code trimme ou
|
||||
* null si absent / vide.
|
||||
*/
|
||||
private function readCategoryCode(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['categoryCode'] ?? null;
|
||||
|
||||
if (!is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = trim($raw);
|
||||
|
||||
return '' === $raw ? null : $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?state=` (PURCHASE / SALE / OTHER). Normalise en majuscules et
|
||||
* n'accepte qu'une valeur de l'enum borne ; toute autre valeur est ignoree (null).
|
||||
*/
|
||||
private function readState(array $context): ?string
|
||||
{
|
||||
$raw = $context['filters']['state'] ?? null;
|
||||
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, self::VALID_STATES, true) ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
|
||||
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readSiteIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['siteId'] ?? null;
|
||||
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Provider StorageType (referentiel lecture seule, ERP-201) :
|
||||
* - LISTE : tri `label ASC` (defaut spec § 4.2), filtre `?siteId[]=` (RG-6.06 :
|
||||
* types disponibles sur au moins un des sites passes) et collection PAGINEE
|
||||
* Hydra (regle ABSOLUE n°13). Echappatoire `?pagination=false` respectee pour
|
||||
* alimenter le multi-select « Type de stockage » du formulaire produit
|
||||
* (referentiel borne — pagination_client_enabled).
|
||||
* - ITEM : lookup simple par id.
|
||||
*
|
||||
* @implements ProviderInterface<StorageType>
|
||||
*/
|
||||
final class StorageTypeProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageTypeRepository')]
|
||||
private readonly StorageTypeRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|StorageType|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
$qb = $this->repository->createListQueryBuilder($this->readSiteIds($context));
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (alimentation du multi-select, referentiel borne).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Pas de fetch-join to-many (sites non serialisee) -> Paginator simple.
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery()));
|
||||
}
|
||||
|
||||
// Get unitaire.
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->repository->findById((int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
|
||||
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readSiteIds(array $context): array
|
||||
{
|
||||
$raw = $context['filters']['siteId'] ?? null;
|
||||
|
||||
if (null === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$ids = [];
|
||||
foreach ($values as $value) {
|
||||
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
|
||||
$ids[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
/**
|
||||
* Export XLSX du catalogue produits (M6, spec-back § 4.5). Jumeau des controllers
|
||||
* d'export ClientExportController (M1) / CarrierExportController (M4) — references
|
||||
* en prose volontairement (pas de {@see} inter-module : violerait la regle
|
||||
* ABSOLUE n°1).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||
* sur la route : sans cela API Platform capterait `/api/products/export.xlsx`
|
||||
* comme l'item `GET /api/products/{id}.{_format}` (id="export", _format="xlsx")
|
||||
* — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des produits (MEMES filtres que
|
||||
* `GET /api/products` via {@see ProductProvider}, deleguee a
|
||||
* {@see ProductRepositoryInterface::createListQueryBuilder()} — l'export
|
||||
* reflete exactement ce que l'utilisateur voit a l'ecran) et mapping metier
|
||||
* des colonnes. Les produits soft-deleted (RG-6.09) sont toujours exclus, comme
|
||||
* en liste (le M6 n'expose jamais le soft-delete, § 2.7).
|
||||
*/
|
||||
#[AsController]
|
||||
final class ProductExportController
|
||||
{
|
||||
/**
|
||||
* Libelles FR des etats (RG-6.02) pour la colonne « États ». L'ordre des cles
|
||||
* fixe l'ordre d'affichage (Achat, Vendu, Autre) independamment de l'ordre de
|
||||
* stockage en base.
|
||||
*/
|
||||
private const array STATE_LABELS = [
|
||||
Product::STATE_PURCHASE => 'Achat',
|
||||
Product::STATE_SALE => 'Vendu',
|
||||
Product::STATE_OTHER => 'Autre',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineProductRepository')]
|
||||
private readonly ProductRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
) {}
|
||||
|
||||
#[Route('/api/products/export.xlsx', name: 'catalog_products_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('catalog.products.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Memes filtres que la vue liste (ProductProvider) pour que l'export
|
||||
// reflete exactement ce que l'utilisateur voit a l'ecran : recherche
|
||||
// (?search), categorie (?categoryId / ?categoryCode), etat (?state),
|
||||
// sites (?siteId[]). includeDeleted reste false : le soft-delete n'est
|
||||
// jamais expose au M6 (§ 2.7).
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
$categoryId = $this->readIntOrNull($request->query->get('categoryId'));
|
||||
$categoryCode = $request->query->getString('categoryCode') ?: null;
|
||||
$state = $this->readState($request->query->get('state'));
|
||||
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
|
||||
|
||||
/** @var list<Product> $products */
|
||||
$products = $this->repository
|
||||
->createListQueryBuilder(false, $search, $categoryId, $categoryCode, $state, $siteIds)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Catalogue produits',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($products),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.5).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Numéro',
|
||||
'Nom',
|
||||
'États',
|
||||
'Catégorie',
|
||||
'Sites',
|
||||
'Types de stockage',
|
||||
'Fabriqué',
|
||||
'Contient mélasse',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Product> $products
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $products): iterable
|
||||
{
|
||||
foreach ($products as $product) {
|
||||
yield [
|
||||
$product->getCode(),
|
||||
$product->getName(),
|
||||
$this->formatStates($product),
|
||||
$product->getCategory()?->getName(),
|
||||
$this->formatSites($product),
|
||||
$this->formatStorageTypes($product),
|
||||
$product->isManufactured() ? 'Oui' : 'Non',
|
||||
$product->containsMolasses() ? 'Oui' : 'Non',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles FR des etats du produit, dans l'ordre canonique (Achat, Vendu,
|
||||
* Autre), joints par virgule. Une valeur inattendue est ignoree.
|
||||
*/
|
||||
private function formatStates(Product $product): string
|
||||
{
|
||||
$states = $product->getStates();
|
||||
|
||||
$labels = [];
|
||||
foreach (self::STATE_LABELS as $code => $label) {
|
||||
if (in_array($code, $states, true)) {
|
||||
$labels[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(', ', $labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des sites de disponibilite du produit, dedupliques, tries, joints
|
||||
* par virgule.
|
||||
*/
|
||||
private function formatSites(Product $product): string
|
||||
{
|
||||
$names = [];
|
||||
foreach ($product->getSites() as $site) {
|
||||
// @var Site $site
|
||||
$name = $site->getName();
|
||||
if (null !== $name && '' !== $name) {
|
||||
$names[$name] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelles des types de stockage du produit, dedupliques, tries, joints par
|
||||
* virgule.
|
||||
*/
|
||||
private function formatStorageTypes(Product $product): string
|
||||
{
|
||||
$labels = [];
|
||||
foreach ($product->getStorageTypes() as $storageType) {
|
||||
// @var StorageType $storageType
|
||||
$label = $storageType->getLabel();
|
||||
if (null !== $label && '' !== $label) {
|
||||
$labels[$label] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->joinSorted($labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $names ensemble de libelles (cles)
|
||||
*/
|
||||
private function joinSorted(array $names): string
|
||||
{
|
||||
$list = array_keys($names);
|
||||
sort($list);
|
||||
|
||||
return implode(', ', $list);
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('catalogue-produits-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le filtre `?state=` comme le ProductProvider : normalise en majuscules
|
||||
* et n'accepte qu'une valeur de l'enum borne {PURCHASE, SALE, OTHER} ; toute
|
||||
* autre valeur est ignoree (null).
|
||||
*/
|
||||
private function readState(mixed $raw): ?string
|
||||
{
|
||||
if (!is_string($raw) || '' === trim($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$state = mb_strtoupper(trim($raw), 'UTF-8');
|
||||
|
||||
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit un identifiant entier positif unique (`?categoryId=`). Aligne sur
|
||||
* ProductProvider (tolere int ou chaine numerique).
|
||||
*/
|
||||
private function readIntOrNull(mixed $raw): ?int
|
||||
{
|
||||
if (is_int($raw)) {
|
||||
return $raw > 0 ? $raw : null;
|
||||
}
|
||||
|
||||
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||
* ou liste, `?siteId[]=`). Aligne sur ProductProvider.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function readIntList(mixed $raw): array
|
||||
{
|
||||
$values = is_array($raw) ? $raw : [$raw];
|
||||
|
||||
$out = [];
|
||||
foreach ($values as $value) {
|
||||
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||
$out[] = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
* (ERP-84 : Negociant, Cooperative...) ; le type PRESTATAIRE porte les categories
|
||||
* prestataires (M3 1.1 : Maintenance industrielle, Nettoyage, Transport) ; le type
|
||||
* ADRESSE porte les categories des blocs adresse (Siege, Contact issues,
|
||||
* Facturation, Livraison, Approvisionnement, Methaniseur) ; le type PRODUIT porte
|
||||
* les categories produit du catalogue (M6 ERP-201 : Cereales, Oleagineux, Aliments
|
||||
* du betail, Engrais). Chaque categorie porte un `code` stable.
|
||||
* Facturation, Livraison, Approvisionnement, Methaniseur). Chaque categorie porte
|
||||
* un `code` stable.
|
||||
* Alimente le repertoire clients (ClientFixtures, module Commercial) avec des
|
||||
* donnees realistes couvrant RG-1.03 (codes DISTRIBUTEUR / COURTIER) et RG-1.29
|
||||
* (codes interdits sur adresse), et le multi-select Categorie fournisseur (M2).
|
||||
@@ -89,15 +88,6 @@ class CategoryFixtures extends Fixture implements DependentFixtureInterface
|
||||
'Approvisionnement' => 'APPROVISIONNEMENT',
|
||||
'Méthaniseur' => 'METHANISEUR',
|
||||
],
|
||||
// M6 (ERP-201) : categories produit alimentant le select du formulaire
|
||||
// produit (filtre ?typeCode=PRODUIT). Codes = slug MAJUSCULE deterministe
|
||||
// (meme sortie que CategoryCodeGenerator). Provisoires, a affiner avec le metier.
|
||||
'PRODUIT' => [
|
||||
'Céréales' => 'CEREALES',
|
||||
'Oléagineux' => 'OLEAGINEUX',
|
||||
'Aliments du bétail' => 'ALIMENTS_DU_BETAIL',
|
||||
'Engrais' => 'ENGRAIS',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -29,10 +29,6 @@ use Doctrine\Persistence\ObjectManager;
|
||||
* dediee au champ « Categorie » des blocs adresse (client + fournisseur). Mirroir
|
||||
* de la migration Version20260625100000.
|
||||
*
|
||||
* M6 (ERP-201) : ajout du type PRODUIT (code PRODUIT, label « Produit »), taxonomie
|
||||
* des categories produit du catalogue (Cereales, Oleagineux...). Mirroir du seed de
|
||||
* la migration Version20260625110000 (ERP-198) : re-aligne dev/test apres purge.
|
||||
*
|
||||
* 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
|
||||
@@ -49,15 +45,14 @@ class CategoryTypeFixtures extends Fixture
|
||||
/**
|
||||
* Source unique des types : code technique => libelle FR. Doit rester aligne
|
||||
* sur le seed des migrations Version20260602100000 (CLIENT),
|
||||
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE),
|
||||
* Version20260625100000 (ADRESSE) et Version20260625110000 (PRODUIT, ERP-198).
|
||||
* Version20260605120000 (FOURNISSEUR), Version20260612080000 (PRESTATAIRE) et
|
||||
* Version20260625100000 (ADRESSE).
|
||||
*/
|
||||
private const TYPES = [
|
||||
'CLIENT' => 'Client',
|
||||
'FOURNISSEUR' => 'Fournisseur',
|
||||
'PRESTATAIRE' => 'Prestataire',
|
||||
'ADRESSE' => 'Adresse',
|
||||
'PRODUIT' => 'Produit',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
|
||||
use App\Shared\Domain\Contract\SiteProviderInterface;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* Fixtures du module Catalog : seed du referentiel `storage_type` (M6).
|
||||
*
|
||||
* ⚠ PROVISOIRE (decision Matthieu 24/06, HP-M6-02) : codes, libelles ET mapping
|
||||
* site ci-dessous sont a REVALIDER / RE-SEEDER quand Aurore livrera la liste et le
|
||||
* mapping definitifs par site. La liste actuelle reprend les 10 valeurs de la
|
||||
* maquette Figma (node 1503-34285) et les rattache PAR DEFAUT aux 3 sites
|
||||
* (Chatellerault 86 / Saint-Jean 17 / Pommevic 82), faute de mapping reel.
|
||||
*
|
||||
* Pourquoi une fixture (et pas un seed de migration) : `storage_type` est une
|
||||
* entite managee par l'ORM, donc le purger Doctrine la vide avant chaque
|
||||
* `doctrine:fixtures:load`. Cette fixture re-aligne dev ET test (le referentiel
|
||||
* doit exister pour alimenter le formulaire produit et les tests du filtre
|
||||
* ?siteId[]= — ERP-203). Elle tourne dans TOUS les environnements (referentiel,
|
||||
* pas une donnee de demo — miroir CategoryTypeFixtures).
|
||||
*
|
||||
* Idempotence : lookup par `code` parmi les types existants avant insertion
|
||||
* (miroir CategoryTypeFixtures). `addSite()` est lui-meme idempotent (contains()).
|
||||
* Rejouable sans doublon meme si le purger Doctrine est desactive.
|
||||
*
|
||||
* Depend de SitesFixtures : les 3 sites doivent etre seedes avant qu'on puisse y
|
||||
* rattacher les types de stockage. Les sites sont resolus via le contrat Shared
|
||||
* SiteProviderInterface (pas d'import du module Sites — regle ABSOLUE n°1).
|
||||
*/
|
||||
class StorageTypeFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Seed PROVISOIRE (Figma node 1503-34285) : code MAJUSCULE stable => libelle FR.
|
||||
* A re-seeder a reception de la liste Aurore (HP-M6-02).
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const TYPES = [
|
||||
'BOISSEAU' => 'Boisseau',
|
||||
'BOISSEAU_DOSAGE' => 'Boisseau dosage',
|
||||
'CASE' => 'Case',
|
||||
'CELLULE' => 'Cellule',
|
||||
'CONTAINER' => 'Container',
|
||||
'CUVE_MELASSE' => 'Cuve mélasse',
|
||||
'STOCKAGE_BIG_BAG' => 'Stockage big bag',
|
||||
'STOCKAGE_PALETTE' => 'Stockage palette',
|
||||
'TAS' => 'Tas',
|
||||
'ZONE' => 'Zone',
|
||||
];
|
||||
|
||||
/**
|
||||
* Noms des 3 sites de rattachement PROVISOIRE (86 / 17 / 82). Le nom est la cle
|
||||
* de lookup stable cote SitesFixtures.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const DEFAULT_SITE_NAMES = ['Chatellerault', 'Saint-Jean', 'Pommevic'];
|
||||
|
||||
public function __construct(
|
||||
private readonly StorageTypeRepositoryInterface $storageTypeRepository,
|
||||
private readonly SiteProviderInterface $siteProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, class-string>
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [SitesFixtures::class];
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Index des types deja presents par code, pour ne pas creer de doublon.
|
||||
$existingByCode = [];
|
||||
foreach ($this->storageTypeRepository->findAllOrderedByLabel() as $type) {
|
||||
$existingByCode[$type->getCode()] = $type;
|
||||
}
|
||||
|
||||
// Resolution des 3 sites par defaut via le contrat Shared (rattachement
|
||||
// provisoire). Les objets resolus sont des Site managees (resolve_target_entities
|
||||
// SiteInterface -> Site) : addSite() les accepte.
|
||||
$defaultSites = [];
|
||||
foreach (self::DEFAULT_SITE_NAMES as $name) {
|
||||
$site = $this->siteProvider->findByName($name);
|
||||
if (null !== $site) {
|
||||
$defaultSites[] = $site;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::TYPES as $code => $label) {
|
||||
$storageType = $existingByCode[$code] ?? new StorageType();
|
||||
$storageType->setCode($code);
|
||||
$storageType->setLabel($label);
|
||||
|
||||
// Rattachement provisoire aux 3 sites (idempotent : addSite -> contains()).
|
||||
foreach ($defaultSites as $site) {
|
||||
$storageType->addSite($site);
|
||||
}
|
||||
|
||||
$manager->persist($storageType);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Repository\ProductRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Product>
|
||||
*/
|
||||
class DoctrineProductRepository extends ServiceEntityRepository implements ProductRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Product::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Product
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(Product $product): void
|
||||
{
|
||||
$this->getEntityManager()->persist($product);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function existsActiveByCode(string $code, ?int $excludeId = null): bool
|
||||
{
|
||||
$qb = $this->createQueryBuilder('p')
|
||||
->select('1')
|
||||
->andWhere('p.code = :code')
|
||||
->andWhere('p.deletedAt IS NULL')
|
||||
->setParameter('code', $code)
|
||||
->setMaxResults(1)
|
||||
;
|
||||
|
||||
if (null !== $excludeId) {
|
||||
$qb->andWhere('p.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||
}
|
||||
|
||||
return [] !== $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(
|
||||
bool $includeDeleted = false,
|
||||
?string $search = null,
|
||||
?int $categoryId = null,
|
||||
?string $categoryCode = null,
|
||||
?string $state = null,
|
||||
array $siteIds = [],
|
||||
): QueryBuilder {
|
||||
// Eager-load des relations embarquees en liste (product:read) pour eviter
|
||||
// un N+1 par produit : category (ManyToOne, sur), sites et storageTypes
|
||||
// (ManyToMany BORNES — embed autorise, ne viole pas la regle n°13). Le
|
||||
// provider enveloppe la requete dans un Paginator(fetchJoinCollection: true),
|
||||
// compatible avec ces fetch-joins to-many (comptage par sous-requete d'ids).
|
||||
$qb = $this->createQueryBuilder('p')
|
||||
->leftJoin('p.category', 'cat')->addSelect('cat')
|
||||
->leftJoin('p.sites', 's')->addSelect('s')
|
||||
->leftJoin('p.storageTypes', 'stp')->addSelect('stp')
|
||||
->orderBy('p.name', 'ASC')
|
||||
;
|
||||
|
||||
// RG-6.09 : la liste exclut par defaut les produits soft-deleted.
|
||||
if (!$includeDeleted) {
|
||||
$qb->andWhere('p.deletedAt IS NULL');
|
||||
}
|
||||
|
||||
// ?search= : recherche partielle case-insensitive sur code + name. Les
|
||||
// metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. Les
|
||||
// deux LIKE sont parenthese pour ne pas casser la precedence AND/OR avec
|
||||
// les autres filtres (AND lie plus fort que OR en DQL).
|
||||
if (null !== $search && '' !== trim($search)) {
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
$qb->andWhere('(LOWER(p.code) LIKE :search OR LOWER(p.name) LIKE :search)')
|
||||
->setParameter('search', $pattern)
|
||||
;
|
||||
}
|
||||
|
||||
// ?categoryId= : filtre par categorie precise (id).
|
||||
if (null !== $categoryId) {
|
||||
$qb->andWhere('cat.id = :categoryId')->setParameter('categoryId', $categoryId);
|
||||
}
|
||||
|
||||
// ?categoryCode= : filtre par categorie precise (code stable).
|
||||
if (null !== $categoryCode && '' !== trim($categoryCode)) {
|
||||
$qb->andWhere('cat.code = :categoryCode')->setParameter('categoryCode', trim($categoryCode));
|
||||
}
|
||||
|
||||
// ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas
|
||||
// exprimer la containment jsonb -> on resout les ids matchant en SQL natif
|
||||
// (operateur @>), puis on contraint le QueryBuilder. Ids vides -> condition
|
||||
// toujours fausse (aucun produit), sans casser le reste de la requete.
|
||||
if (null !== $state) {
|
||||
$stateIds = $this->matchingStateIds($state);
|
||||
if ([] === $stateIds) {
|
||||
$qb->andWhere('1 = 0');
|
||||
} else {
|
||||
$qb->andWhere('p.id IN (:stateIds)')->setParameter('stateIds', $stateIds);
|
||||
}
|
||||
}
|
||||
|
||||
// ?siteId[]= : produit disponible sur AU MOINS UN des sites passes (OR).
|
||||
// Sous-requete EXISTS correlee pour ne PAS restreindre la collection sites
|
||||
// eager-loadee `s` (sinon les autres sites du produit disparaitraient du
|
||||
// JSON) et eviter les lignes dupliquees (cf. DoctrineCategoryRepository).
|
||||
if ([] !== $siteIds) {
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(Product::class, 'p_si')
|
||||
->join('p_si.sites', 's_si')
|
||||
->where('p_si = p')
|
||||
->andWhere('s_si.id IN (:siteIds)')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('siteIds', $siteIds)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids des produits dont la colonne JSONB `states` contient l'etat donne, via
|
||||
* l'operateur de containment Postgres `@>`. L'etat est borne a l'enum
|
||||
* {PURCHASE, SALE, OTHER} en amont (ProductProvider) — pas de saisie libre ici.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function matchingStateIds(string $state): array
|
||||
{
|
||||
$rows = $this->getEntityManager()->getConnection()
|
||||
->executeQuery(
|
||||
'SELECT id FROM product WHERE states @> CAST(:state AS JSONB)',
|
||||
['state' => (string) json_encode([$state])],
|
||||
)
|
||||
->fetchFirstColumn()
|
||||
;
|
||||
|
||||
return array_map(static fn (mixed $id): int => (int) $id, $rows);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Catalog\Domain\Repository\StorageTypeRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<StorageType>
|
||||
*/
|
||||
class DoctrineStorageTypeRepository extends ServiceEntityRepository implements StorageTypeRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, StorageType::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?StorageType
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<StorageType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array
|
||||
{
|
||||
return $this->findBy([], ['label' => 'ASC']);
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(array $siteIds = []): QueryBuilder
|
||||
{
|
||||
// Tri alphabetique stable (multi-select du formulaire produit, § 4.2). La
|
||||
// relation `sites` n'est PAS serialisee (storage_type:read ne la porte pas)
|
||||
// -> pas d'eager-load : le filtre n'affecte pas la sortie, seulement la
|
||||
// restriction des lignes.
|
||||
$qb = $this->createQueryBuilder('st')
|
||||
->orderBy('st.label', 'ASC')
|
||||
;
|
||||
|
||||
// ?siteId[]= : type disponible sur AU MOINS UN des sites passes (OR, RG-6.06).
|
||||
// Sous-requete EXISTS correlee (meme strategie que DoctrineCategoryRepository
|
||||
// / DoctrineProductRepository) pour eviter les lignes dupliquees du JOIN.
|
||||
if ([] !== $siteIds) {
|
||||
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('1')
|
||||
->from(StorageType::class, 'st_si')
|
||||
->join('st_si.sites', 's_si')
|
||||
->where('st_si = st')
|
||||
->andWhere('s_si.id IN (:siteIds)')
|
||||
;
|
||||
$qb->andWhere($qb->expr()->exists($sub->getDQL()))
|
||||
->setParameter('siteIds', $siteIds)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
@@ -186,10 +186,6 @@ final class SeedE2ECommand extends Command
|
||||
'sites.bypass_scope',
|
||||
'catalog.categories.view',
|
||||
'catalog.categories.manage',
|
||||
// Catalogue produit (M6, ERP-197). Admin-only (matrice docx
|
||||
// p.3) : mappe sur le persona "tout". Miroir de personas.ts.
|
||||
'catalog.products.view',
|
||||
'catalog.products.manage',
|
||||
// Commercial — Repertoire clients (M1). Mappe ici sur le
|
||||
// persona "tout" en attendant les vrais roles metier
|
||||
// (bureau/compta/commerciale/usine) seedes par ERP-74.
|
||||
|
||||
@@ -575,47 +575,6 @@ final class ColumnCommentsCatalog
|
||||
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// M6 Catalog (ERP-199) — tables desormais mappees par les entites
|
||||
// Product / StorageType : schema:update (test) les recree sans COMMENT
|
||||
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
|
||||
// identiques aux COMMENT de la migration Version20260625110000 (ERP-198).
|
||||
'storage_type' => [
|
||||
'_table' => 'Referentiel des types de stockage (PROVISOIRE, en attente liste Aurore) — Boisseau, Cellule, Tas, Cuve melasse… (RG-6.06). Lecture seule au M6.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code stable MAJUSCULE du type de stockage (ex. TAS, CUVE_MELASSE). Unique (uq_storage_type_code).',
|
||||
'label' => 'Libelle FR affiche du type de stockage (ex. « Cuve melasse »).',
|
||||
],
|
||||
|
||||
'storage_type_site' => [
|
||||
'_table' => 'Jointure M2M storage_type <-> site (Sites) — sites sur lesquels un type de stockage est disponible (alimente le filtrage du multi-select par site, RG-6.06).',
|
||||
'storage_type_id' => 'FK -> storage_type.id, ON DELETE CASCADE — type de stockage disponible.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE CASCADE — site ou le type de stockage est disponible.',
|
||||
],
|
||||
|
||||
'product' => [
|
||||
'_table' => 'Produits du catalogue (M6 Catalog) — etat Achat/Vendu/Autre, sites de disponibilite, categorie produit, types de stockage.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'code' => 'Code produit (= « Numero » de la liste), saisi, unique global parmi les actifs (RG-6.01). Index partiel uq_product_code_active. Normalise serveur (trim/UPPER).',
|
||||
'name' => 'Nom du produit (≤ 255). Normalise serveur (trim).',
|
||||
'states' => 'Etats du produit (JSON) : sous-ensemble non vide de PURCHASE|SALE|OTHER, multi-select (RG-6.02, chk_product_states_not_empty). Pilote les champs conditionnels.',
|
||||
'manufactured' => '« Fabrique » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).',
|
||||
'contains_molasses' => '« Contient de la melasse » : saisi uniquement si states contient SALE, sinon force false serveur (RG-6.03).',
|
||||
'category_id' => 'Categorie produit (FK -> category.id, ON DELETE RESTRICT) — type PRODUIT, obligatoire, validee applicativement (RG-6.05).',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — non expose au M6 ; la liste exclut les produits supprimes (§ 2.7). Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'product_site' => [
|
||||
'_table' => 'Jointure M2M product <-> site (Sites) — sites de disponibilite du produit (>= 1 obligatoire, RG-6.04).',
|
||||
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
|
||||
'site_id' => 'FK -> site.id, ON DELETE RESTRICT — site de disponibilite rattache au produit.',
|
||||
],
|
||||
|
||||
'product_storage_type' => [
|
||||
'_table' => 'Jointure M2M product <-> storage_type — types de stockage du produit (>= 1 obligatoire, filtres par les sites selectionnes, RG-6.06).',
|
||||
'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.',
|
||||
'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\Country;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
@@ -56,10 +55,6 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
* - CategoryType : referentiel statique (codes de typage des categories),
|
||||
* pas de besoin de tracabilite user-driven (cree par migration/seed,
|
||||
* pas pilote utilisateur au M0). Cf. spec-back § 2.8.bis + RG-1.17.
|
||||
* - StorageType (M6, ERP-199) : referentiel PROVISOIRE des types de stockage
|
||||
* (en attente liste Aurore — HP-M6-02), cree par migration + seede (ERP-201),
|
||||
* lecture seule au M6. Pas de tracabilite user-driven, meme justification que
|
||||
* CategoryType. Cf. spec-back M6 § 2.4 + § 2.6.
|
||||
* - TvaMode / PaymentDelay / PaymentType / Bank (M1 Commercial) : referentiels
|
||||
* comptables statiques (id/code/label/position), seedes par migration +
|
||||
* CommercialReferentialFixtures, lecture seule au M1 (HP-M2-2). Pas de
|
||||
@@ -80,7 +75,6 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
StorageType::class,
|
||||
TvaMode::class,
|
||||
PaymentDelay::class,
|
||||
PaymentType::class,
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'export XLSX du catalogue produits (M6, § 4.5).
|
||||
*
|
||||
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes de
|
||||
* colonnes), exclusion des produits soft-deleted par defaut (RG-6.09), respect
|
||||
* des filtres ?search et ?state, peuplement des colonnes metier (etats joints,
|
||||
* categorie, sites, types de stockage, fabrique / contient melasse), 403 sans
|
||||
* catalog.products.view, 401 anonyme.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ProductExportControllerTest extends AbstractCatalogApiTestCase
|
||||
{
|
||||
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
private const string EXPORT_URL = '/api/products/export.xlsx';
|
||||
|
||||
/**
|
||||
* Purge des produits + types de stockage de test AVANT le cleanup parent :
|
||||
* product reference category / site / storage_type en FK ON DELETE RESTRICT,
|
||||
* donc les categories ne peuvent etre supprimees tant que des produits les
|
||||
* referencent. La suppression des produits cascade les jonctions
|
||||
* product_site / product_storage_type au niveau base (ON DELETE CASCADE).
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$em->createQuery('DELETE FROM '.Product::class)->execute();
|
||||
$em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix')
|
||||
->setParameter('prefix', self::TEST_CATEGORY_TYPE_PREFIX.'%')
|
||||
->execute()
|
||||
;
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testExportReturnsXlsxResponseWithHeaderRow(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_A', 'Export Alpha');
|
||||
|
||||
$response = $client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$headers = $response->getHeaders(false);
|
||||
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
|
||||
|
||||
$disposition = $headers['content-disposition'][0] ?? '';
|
||||
self::assertStringContainsString('attachment; filename="catalogue-produits-', $disposition);
|
||||
self::assertMatchesRegularExpression(
|
||||
'/filename="catalogue-produits-\d{8}\.xlsx"/',
|
||||
$disposition,
|
||||
);
|
||||
|
||||
// Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes.
|
||||
$grid = $this->gridFromResponse($response->getContent());
|
||||
$headerCells = $grid[0];
|
||||
self::assertSame('Numéro', $headerCells[0]);
|
||||
self::assertSame('Nom', $headerCells[1]);
|
||||
self::assertContains('États', $headerCells);
|
||||
self::assertContains('Catégorie', $headerCells);
|
||||
self::assertContains('Sites', $headerCells);
|
||||
self::assertContains('Types de stockage', $headerCells);
|
||||
self::assertContains('Fabriqué', $headerCells);
|
||||
self::assertContains('Contient mélasse', $headerCells);
|
||||
|
||||
// Au moins une ligne de donnees (le produit seede).
|
||||
self::assertContains('TEST_PRD_A', $this->codes($response->getContent()));
|
||||
}
|
||||
|
||||
public function testExportExcludesSoftDeletedByDefault(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_ACTIVE', 'Active One');
|
||||
$this->seedProduct('TEST_PRD_DELETED', 'Deleted One', deletedAt: new DateTimeImmutable());
|
||||
|
||||
$codes = $this->codes($client->request('GET', self::EXPORT_URL)->getContent());
|
||||
|
||||
self::assertContains('TEST_PRD_ACTIVE', $codes);
|
||||
self::assertNotContains('TEST_PRD_DELETED', $codes);
|
||||
}
|
||||
|
||||
public function testExportRespectsSearchFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_SRCH', 'Searchable Alpha');
|
||||
$this->seedProduct('TEST_PRD_OTHER', 'Other Beta');
|
||||
|
||||
$codes = $this->codes(
|
||||
$client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('TEST_PRD_SRCH', $codes);
|
||||
self::assertNotContains('TEST_PRD_OTHER', $codes);
|
||||
}
|
||||
|
||||
public function testExportRespectsStateFilter(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedProduct('TEST_PRD_SALE', 'Sold One', [Product::STATE_SALE]);
|
||||
$this->seedProduct('TEST_PRD_BUY', 'Bought One', [Product::STATE_PURCHASE]);
|
||||
|
||||
$codes = $this->codes(
|
||||
$client->request('GET', self::EXPORT_URL.'?state=SALE')->getContent(),
|
||||
);
|
||||
|
||||
self::assertContains('TEST_PRD_SALE', $codes);
|
||||
self::assertNotContains('TEST_PRD_BUY', $codes);
|
||||
}
|
||||
|
||||
public function testExportPopulatesAllBusinessColumns(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$site = $this->firstSite();
|
||||
$storageType = $this->seedStorageType('TEST_STP', 'Tas de test');
|
||||
$category = $this->createCategory('test_cat_export_produit');
|
||||
|
||||
$this->seedProduct(
|
||||
'TEST_PRD_FULL',
|
||||
'Complet',
|
||||
[Product::STATE_PURCHASE, Product::STATE_SALE],
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
$site,
|
||||
$storageType,
|
||||
$category,
|
||||
);
|
||||
|
||||
$row = $this->rowForCode($client->request('GET', self::EXPORT_URL)->getContent(), 'TEST_PRD_FULL');
|
||||
self::assertNotNull($row, 'Le produit seede est absent de l\'export.');
|
||||
|
||||
// 0 Numéro | 1 Nom | 2 États | 3 Catégorie | 4 Sites | 5 Types de stockage | 6 Fabriqué | 7 Contient mélasse
|
||||
self::assertSame('TEST_PRD_FULL', $row[0]);
|
||||
self::assertSame('Complet', $row[1]);
|
||||
self::assertSame('Achat, Vendu', $row[2]);
|
||||
self::assertSame((string) $category->getName(), $row[3]);
|
||||
self::assertSame((string) $site->getName(), $row[4]);
|
||||
self::assertSame('Tas de test', $row[5]);
|
||||
self::assertSame('Oui', $row[6]);
|
||||
self::assertSame('Oui', $row[7]);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProductsViewPermission(): void
|
||||
{
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testUnauthorizedWhenAnonymous(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', self::EXPORT_URL);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede un produit complet (categorie + 1 site + 1 type de stockage par
|
||||
* defaut). Les relations omises sont creees a la volee. Persistance directe
|
||||
* via l'EM : on bypasse le Processor/Validator (non teste ici).
|
||||
*
|
||||
* @param list<string> $states
|
||||
*/
|
||||
private function seedProduct(
|
||||
string $code,
|
||||
string $name,
|
||||
array $states = [Product::STATE_PURCHASE],
|
||||
bool $manufactured = false,
|
||||
bool $containsMolasses = false,
|
||||
?DateTimeImmutable $deletedAt = null,
|
||||
?Site $site = null,
|
||||
?StorageType $storageType = null,
|
||||
?Category $category = null,
|
||||
): Product {
|
||||
$em = $this->getEm();
|
||||
|
||||
$product = new Product();
|
||||
$product->setCode($code);
|
||||
$product->setName($name);
|
||||
$product->setStates($states);
|
||||
$product->setManufactured($manufactured);
|
||||
$product->setContainsMolasses($containsMolasses);
|
||||
$product->setCategory($category ?? $this->createCategory());
|
||||
$product->addSite($site ?? $this->firstSite());
|
||||
$product->addStorageType($storageType ?? $this->seedStorageType('TEST_'.strtoupper(substr(bin2hex(random_bytes(4)), 0, 8))));
|
||||
$product->setDeletedAt($deletedAt);
|
||||
|
||||
$em->persist($product);
|
||||
$em->flush();
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un type de stockage de test (code prefixe TEST_ pour le cleanup).
|
||||
*/
|
||||
private function seedStorageType(string $code, string $label = 'Type de stockage de test'): StorageType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
$storageType = new StorageType();
|
||||
$storageType->setCode($code);
|
||||
$storageType->setLabel($label);
|
||||
|
||||
$em->persist($storageType);
|
||||
$em->flush();
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premier site seede (les sites existent en base de test, comme dans les
|
||||
* autres tests d'export).
|
||||
*/
|
||||
private function firstSite(): Site
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de seeder un produit.');
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
|
||||
*
|
||||
* @return array<int, array<int, mixed>>
|
||||
*/
|
||||
private function gridFromResponse(string $binary): array
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_export_test_');
|
||||
self::assertIsString($tmp);
|
||||
file_put_contents($tmp, $binary);
|
||||
|
||||
try {
|
||||
return IOFactory::load($tmp)->getActiveSheet()->toArray();
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la colonne « Numéro » (1re colonne) des lignes de donnees.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function codes(string $binary): array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
$rows = array_slice($grid, 1); // saute l'en-tete
|
||||
|
||||
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie la ligne de donnees dont la colonne « Numéro » vaut $code, ou null.
|
||||
*
|
||||
* @return null|array<int, mixed>
|
||||
*/
|
||||
private function rowForCode(string $binary, string $code): ?array
|
||||
{
|
||||
$grid = $this->gridFromResponse($binary);
|
||||
foreach (array_slice($grid, 1) as $row) {
|
||||
if ((string) ($row[0] ?? '') === $code) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user