7075f0f95d
- Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique. - PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier). - StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif). - Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée. - Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré.
271 lines
11 KiB
PHP
271 lines
11 KiB
PHP
<?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\Unique(message: 'Chaque état ne peut être sélectionné qu\'une seule fois.')]
|
|
#[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
|
|
{
|
|
// `array_values` reseque toujours un tableau SEQUENTIEL : une saisie cliente
|
|
// malformee (objet JSON `{"x":"RECEPTION"}` denormalise en tableau associatif)
|
|
// ne peut plus etre persistee comme un objet JSONB, ce qui ferait echouer le
|
|
// CHECK chk_storage_states_not_empty (jsonb_array_length sur non-tableau) en
|
|
// 500. Les doublons eventuels restent rejetes en 422 par Assert\Unique.
|
|
$this->states = array_values($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 ?? ''));
|
|
}
|
|
}
|