Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c4c34c1a3 | |||
| ca9dbe583a | |||
| c9c6d043a7 |
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user