Compare commits

...

3 Commits

Author SHA1 Message Date
tristan 8c4c34c1a3 feat(catalog) : M7 — entité Storage + repository + contrat de sérialisation (ERP-212) 2026-06-29 16:27:02 +02:00
tristan ca9dbe583a feat(catalog) : M7 — migration table storage (FK site/storage_type, unicité métier RG-7.01, états JSONB RG-7.04) (ERP-211) 2026-06-29 15:46:12 +02:00
tristan c9c6d043a7 feat(catalog) : M7 — permissions catalog.storages.* + sidebar + 3 miroirs RBAC (ERP-210)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 50s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m5s
2026-06-29 15:24:08 +02:00
11 changed files with 493 additions and 5 deletions
+10
View File
@@ -172,6 +172,16 @@ return [
'module' => 'catalog',
'permission' => 'catalog.categories.view',
],
// Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view`
// et son module owner `catalog`. Reutilise le referentiel StorageType
// du M6. Place pres des autres items Catalog (produits, categories).
[
'label' => 'sidebar.catalog.storages',
'to' => '/admin/storages',
'icon' => 'mdi:warehouse',
'module' => 'catalog',
'permission' => 'catalog.storages.view',
],
[
'label' => 'sidebar.core.audit_log',
'to' => '/admin/audit-log',
+3 -1
View File
@@ -53,7 +53,8 @@
},
"catalog": {
"categories": "Gestion des catégories",
"products": "Catalogue produits"
"products": "Catalogue produits",
"storages": "Gestion des stockages"
}
},
"dashboard": {
@@ -819,6 +820,7 @@
"sites_site": "Site",
"catalog_category": "Catégorie",
"catalog_product": "Produit",
"catalog_storage": "Stockage",
"commercial_client": "Client",
"commercial_clientaddress": "Adresse client",
"commercial_clientcontact": "Contact client",
+10 -4
View File
@@ -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' | 'products' | 'storages'>
}
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', 'products', 'storages', 'audit-log'],
},
'user-full': {
key: 'user-full',
@@ -71,6 +71,12 @@ export const personas: Record<PersonaKey, Persona> = {
// `/admin/products` -> ajoute le lien `products` a expectedAdminLinks.
'catalog.products.view',
'catalog.products.manage',
// Stockage (M7, ERP-210). Admin-only : mappe sur le persona "tout",
// pas de nouveau persona (regle ABSOLUE n°7). L'item vit dans la
// section Administration sur la route `/admin/storages` -> ajoute le
// lien `storages` a expectedAdminLinks.
'catalog.storages.view',
'catalog.storages.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
@@ -121,7 +127,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', 'products', 'storages', 'audit-log'],
},
'user-readonly': {
key: 'user-readonly',
@@ -166,4 +172,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', 'products', 'storages', 'audit-log'] as const
+1
View File
@@ -234,6 +234,7 @@ test-db-setup:
$(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"
$(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL"
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+136
View File
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Shared\Infrastructure\Database\ColumnCommentsCatalog;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* M7 — Stockage (ERP-211) : creation du schema BDD de la table `storage`.
*
* Un stockage = 1 site + 1 type de stockage (referentiel storage_type du M6) + 1
* numero, avec des etats multi-valeur (JSONB), soft-delete prepare et colonnes
* Timestampable/Blamable (spec-back § 3.2).
*
* Contraintes metier portees ici :
* - RG-7.01 : unicite du couple (site, type, numero) parmi les stockages ACTIFS,
* via l'index UNIQUE partiel uq_storage_site_type_numero_active (WHERE deleted_at
* IS NULL) — un numero peut etre reutilise apres soft-delete.
* - RG-7.04 : au moins un etat, via chk_storage_states_not_empty
* (jsonb_array_length(states) >= 1). Comme pour product.states (M6), PAS de
* DEFAULT '[]'::jsonb : un tableau vide violerait ce CHECK ; la colonne est
* toujours renseignee par l'app (Processor/ORM).
*
* Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : la
* table storage porte des FK cross-module (site, storage_type, user). Le tri par
* timestamp au sein du namespace racine garantit l'ordre apres la creation de ces
* tables sur base vide ; un namespace modulaire trierait par FQCN alphabetique et
* casserait `make db-reset` (cf. Version20260625110000 pour le M6).
*
* 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) : `storage` sera mappee en ORM au ticket suivant
* (entite Storage). D'ici la, `schema:update --force` la drope sur la base de TEST
* uniquement (sans impact : aucun test ne la reference encore, et dev/prod ne lancent
* jamais schema:update). Sa description sera ajoutee a ColumnCommentsCatalog au ticket
* entite (comme product / weighing_ticket).
*/
final class Version20260629120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-211 (M7) : creation de la table storage (FK site + storage_type, unicite metier partielle RG-7.01, etats JSONB RG-7.04, soft-delete + Timestampable/Blamable).';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE storage (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
site_id INT NOT NULL,
storage_type_id INT NOT NULL,
numero VARCHAR(50) NOT NULL,
-- Pas de DEFAULT : un tableau vide violerait chk_storage_states_not_empty
-- (RG-7.04). La colonne est toujours renseignee par l'app (Processor/ORM).
states JSONB 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_storage_states_not_empty
CHECK (jsonb_array_length(states) >= 1),
CONSTRAINT fk_storage_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT,
CONSTRAINT fk_storage_storage_type
FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT,
CONSTRAINT fk_storage_created_by
FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL,
CONSTRAINT fk_storage_updated_by
FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL
)
SQL);
// RG-7.01 : unicite (site, type, numero) parmi les stockages actifs uniquement
// (index partiel) — un numero redevient disponible apres soft-delete.
$this->addSql('CREATE UNIQUE INDEX uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL');
$this->addSql('CREATE INDEX idx_storage_site ON storage (site_id)');
$this->addSql('CREATE INDEX idx_storage_storage_type ON storage (storage_type_id)');
$this->addSql('CREATE INDEX idx_storage_deleted_at ON storage (deleted_at)');
$this->addSql('CREATE INDEX idx_storage_created_by ON storage (created_by)');
$this->addSql('CREATE INDEX idx_storage_updated_by ON storage (updated_by)');
$this->comment('storage', '_table', 'Emplacements de stockage (M7 Catalog) un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.');
$this->comment('storage', 'id', 'Identifiant interne auto-incremente.');
$this->comment('storage', 'site_id', 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).');
$this->comment('storage', 'storage_type_id', 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).');
$this->comment('storage', 'numero', 'Numero du stockage ( 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.');
$this->comment('storage', 'states', 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.');
$this->comment('storage', 'deleted_at', 'Horodatage du soft-delete technique null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).');
$this->addTimestampableBlamableComments('storage');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE storage');
}
/**
* 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,
));
}
}
+5
View File
@@ -47,6 +47,11 @@ final class CatalogModule
// 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)'],
// Stockage (M7, ERP-210) : admin-only. Reutilise le referentiel
// StorageType du M6. Item sidebar dans la section Administration,
// pres des items Catalog (produits, categories).
['code' => 'catalog.storages.view', 'label' => 'Voir les stockages'],
['code' => 'catalog.storages.manage', 'label' => 'Gérer les stockages (créer, éditer)'],
];
}
}
@@ -0,0 +1,264 @@
<?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\StorageProcessor;
use App\Module\Catalog\Infrastructure\ApiPlatform\State\Provider\StorageProvider;
use App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository;
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\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Emplacement de stockage (M7 Catalog) — entite racine du module Stockage, jumelle
* de Product (M6) cote pattern (#[Auditable], TimestampableBlamable, soft-delete,
* etats multi-valeur JSONB) et cote contrat de serialisation (RETEX M1 — spec § 4.0).
*
* Un stockage = 1 site + 1 type de stockage (referentiel storage_type du M6) + 1
* numero. Le couple (site, type, numero) est unique parmi les stockages ACTIFS
* (RG-7.01, index partiel uq_storage_site_type_numero_active possede par la
* migration). Les etats (RECEPTION / PRODUCTION / TRIAGE) sont multi-valeur, au
* moins un (RG-7.04, CHECK chk_storage_states_not_empty).
*
* Contrat de serialisation :
* - LISTE / DETAIL (storage:read + site:read + storage_type:read + default:read) :
* numero, states, displayName (RG-7.05), site et storageType embarques (ensembles
* bornes -> embed autorise, ne viole pas la regle n°13), createdAt/updatedAt
* (via default:read). L'ecriture passe par storage:write (site, storageType,
* numero, states).
*
* Soft-delete prepare via `deletedAt` (non expose, § 2.8) : pas de Delete dans les
* operations ; la liste exclut les stockages supprimes (Provider, ERP-213). Un
* numero redevient disponible apres soft-delete (index partiel sur les actifs).
*
* NB : `Site` appartient au module Sites, consomme en relation ORM partagee (§ 2.1)
* — on reutilise son read-group `site:read`, sans logique inter-module. `StorageType`
* est dans le meme module Catalog.
*
* @see StorageProvider Lecture (liste paginee filtree soft-delete + item) — ERP-213.
* @see StorageProcessor Ecriture (normalisation, unicite metier RG-7.01) — ERP-213.
*/
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('catalog.storages.view')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
provider: StorageProvider::class,
),
new Get(
security: "is_granted('catalog.storages.view')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
provider: StorageProvider::class,
),
new Post(
security: "is_granted('catalog.storages.manage')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['storage:write']],
// Convertit les erreurs de denormalisation (type invalide / null sur une
// relation : site, storageType) en violations 422 portant un propertyPath,
// au lieu d'un 400 qui court-circuite la validation (cf. Product — mapping
// inline useFormErrors, ERP-101).
collectDenormalizationErrors: true,
processor: StorageProcessor::class,
),
new Patch(
security: "is_granted('catalog.storages.manage')",
normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']],
denormalizationContext: ['groups' => ['storage:write']],
collectDenormalizationErrors: true,
provider: StorageProvider::class,
processor: StorageProcessor::class,
),
// Pas de Delete au M7 (§ 2.8) ; soft-delete prepare non expose.
],
)]
#[ORM\Entity(repositoryClass: DoctrineStorageRepository::class)]
#[ORM\Table(name: 'storage')]
// Index nommes pour matcher la migration (cf. Product). L'index unique partiel
// `uq_storage_site_type_numero_active` ((site, type, numero) WHERE deleted_at IS
// NULL — unicite metier parmi les actifs, RG-7.01) reste possede par la seule
// migration : Doctrine ORM ne sait pas exprimer un index partiel via attribut.
#[ORM\Index(name: 'idx_storage_site', columns: ['site_id'])]
#[ORM\Index(name: 'idx_storage_storage_type', columns: ['storage_type_id'])]
#[ORM\Index(name: 'idx_storage_deleted_at', columns: ['deleted_at'])]
#[ORM\Index(name: 'idx_storage_created_by', columns: ['created_by'])]
#[ORM\Index(name: 'idx_storage_updated_by', columns: ['updated_by'])]
#[Auditable]
class Storage 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 stockage (RG-7.04) — valeurs autorisees de la colonne JSONB `states`. */
public const string STATE_RECEPTION = 'RECEPTION';
public const string STATE_PRODUCTION = 'PRODUCTION';
public const string STATE_TRIAGE = 'TRIAGE';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['storage:read'])]
private ?int $id = null;
// Site du stockage (obligatoire). FK ON DELETE RESTRICT : un site reference par
// un stockage ne peut etre supprime. Composante de l'unicite metier (RG-7.01).
#[ORM\ManyToOne(targetEntity: Site::class)]
#[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Le site est obligatoire.')]
#[Groups(['storage:read', 'storage:write'])]
private ?Site $site = null;
// Type de stockage (obligatoire, referentiel plat M6). FK ON DELETE RESTRICT :
// un type reference par un stockage ne peut etre supprime. Composante de
// l'unicite metier (RG-7.01).
#[ORM\ManyToOne(targetEntity: StorageType::class)]
#[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Assert\NotNull(message: 'Le type de stockage est obligatoire.')]
#[Groups(['storage:read', 'storage:write'])]
private ?StorageType $storageType = null;
// Numero du stockage, saisi. Unique par (site, type) parmi les actifs (RG-7.01).
// Normalise serveur (trim) par le StorageProcessor (ERP-213).
#[ORM\Column(length: 50)]
#[Assert\NotBlank(message: 'Le numéro du stockage est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 50, maxMessage: 'Le numéro du stockage ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Groups(['storage:read', 'storage:write'])]
private ?string $numero = null;
/**
* Etats du stockage (multi-select), sous-ensemble non vide de
* {RECEPTION, PRODUCTION, TRIAGE} (RG-7.04). Stocke en JSONB (tableau de
* chaines), non-vacuite garantie aussi par le CHECK chk_storage_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 (cf. Product::states).
*
* @var list<string>
*/
// jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la
// migration (CHECK chk_storage_states_not_empty via jsonb_array_length). Sans
// `options: ['jsonb' => true]`, schema:update tente un ALTER states TYPE JSON
// qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states).
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')]
#[Assert\Choice(
choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE],
multiple: true,
message: 'État de stockage invalide.',
multipleMessage: 'État de stockage invalide.',
)]
#[Groups(['storage:read', 'storage:write'])]
private array $states = [];
/**
* Soft-delete technique : null = actif, valeur = supprime logiquement le {date}.
* Non expose (§ 2.8, aucun groupe) : prepare pour une future suppression. La
* liste exclut par defaut les stockages supprimes (Provider, ERP-213) et le
* numero redevient disponible (index partiel sur les actifs, RG-7.01).
*/
#[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)]
private ?DateTimeImmutable $deletedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
public function getStorageType(): ?StorageType
{
return $this->storageType;
}
public function setStorageType(?StorageType $storageType): static
{
$this->storageType = $storageType;
return $this;
}
public function getNumero(): ?string
{
return $this->numero;
}
public function setNumero(string $numero): static
{
$this->numero = $numero;
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 getDeletedAt(): ?DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
/**
* RG-7.05 : libelle d'affichage = libelle du type de stockage suivi du numero
* (ex. « Cellule 12 »). Getter virtuel non persiste, expose en lecture
* (storage:read). Null-safe : `storageType` et `numero` sont garantis non nuls a
* la lecture (NOT NULL en base), le `?? ''` couvre un objet en cours de
* construction sans casser la serialisation.
*/
#[Groups(['storage:read'])]
public function getDisplayName(): string
{
$label = $this->storageType?->getLabel() ?? '';
return trim($label.' '.($this->numero ?? ''));
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Domain\Repository;
use App\Module\Catalog\Domain\Entity\Storage;
interface StorageRepositoryInterface
{
public function findById(int $id): ?Storage;
public function save(Storage $storage): void;
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Doctrine;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Storage>
*/
class DoctrineStorageRepository extends ServiceEntityRepository implements StorageRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Storage::class);
}
public function findById(int $id): ?Storage
{
return $this->find($id);
}
public function save(Storage $storage): void
{
$this->getEntityManager()->persist($storage);
$this->getEntityManager()->flush();
}
}
@@ -190,6 +190,10 @@ final class SeedE2ECommand extends Command
// p.3) : mappe sur le persona "tout". Miroir de personas.ts.
'catalog.products.view',
'catalog.products.manage',
// Stockage (M7, ERP-210). Admin-only : mappe sur le persona
// "tout". Miroir de personas.ts.
'catalog.storages.view',
'catalog.storages.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.
@@ -610,6 +610,20 @@ final class ColumnCommentsCatalog
'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.',
],
// M7 Catalog (ERP-212) — table desormais mappee par l'entite Storage :
// schema:update (test) la recree sans COMMENT -> app:apply-column-comments
// les rejoue depuis ce catalogue. Strings identiques aux COMMENT de la
// migration Version20260629120000 (ERP-211).
'storage' => [
'_table' => 'Emplacements de stockage (M7 Catalog) — un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.',
'id' => 'Identifiant interne auto-incremente.',
'site_id' => 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).',
'storage_type_id' => 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).',
'numero' => 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.',
'states' => 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.',
'deleted_at' => 'Horodatage du soft-delete technique — null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).',
] + self::timestampableBlamableComments(),
];
}