feat(catalog) : ERP-199 — entités Product + StorageType + repositories + contrat de sérialisation
Entité Product (#[Auditable], TimestampableBlamable, soft-delete préparé non exposé) et référentiel StorageType (lecture seule, provisoire) dans le module Catalog, avec le contrat de sérialisation posé une fois (read-groups par propriété affichée — RETEX M1→M5, 3 maillons spec § 4.0). - Product : code (unique global RG-6.01), name, states (json multi-select PURCHASE/SALE/OTHER ≥1, RG-6.02), manufactured/containsMolasses (RG-6.03), category ManyToOne (PRODUIT, RG-6.05), sites + storageTypes ManyToMany (≥1). Messages FR sur toutes les contraintes, Length calée colonnes. Opérations Get/GetCollection (.view) + Post/Patch (.manage), pas de Delete. Provider/ Processor référencés (implémentés en ERP-200). - StorageType : code/label + sites ManyToMany (filtrage par site, ERP-201). Référentiel statique → whitelist EntitiesAreTimestampableBlamableTest. - Repositories Product/StorageType (interfaces Domain + impl Doctrine). - Validation états via Assert\Choice(multiple) plutôt qu'Assert\All (seul Choice est géré par EntityConstraintsHaveFrenchMessageTest). - Garde-fous schema:update : 5 tables M6 ajoutées à ColumnCommentsCatalog, index partiel uq_product_code_active rejoué dans makefile test-db-setup. - i18n audit.entity.catalog_product.
This commit is contained in:
@@ -817,6 +817,7 @@
|
||||
"core_permission": "Permission",
|
||||
"sites_site": "Site",
|
||||
"catalog_category": "Catégorie",
|
||||
"catalog_product": "Produit",
|
||||
"commercial_client": "Client",
|
||||
"commercial_clientaddress": "Adresse client",
|
||||
"commercial_clientcontact": "Contact client",
|
||||
|
||||
@@ -233,6 +233,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
#[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>
|
||||
*/
|
||||
#[ORM\Column(type: 'json')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état (Achat, Vendu ou Autre).')]
|
||||
#[Assert\Choice(
|
||||
choices: ['PURCHASE', 'SALE', '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?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\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: [
|
||||
new GetCollection(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['storage_type:read']],
|
||||
// Tri alphabetique stable pour alimenter le multi-select du formulaire
|
||||
// produit (§ 4.2). Le filtre ?siteId[]= est branche en ERP-201.
|
||||
order: ['label' => 'ASC'],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('catalog.products.view')",
|
||||
normalizationContext: ['groups' => ['storage_type:read']],
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Product;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Catalog\Domain\Repository;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
|
||||
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). Le filtrage par site (?siteId[]=, RG-6.06) est
|
||||
* branche cote provider en ERP-201.
|
||||
*
|
||||
* @return list<StorageType>
|
||||
*/
|
||||
public function findAllOrderedByLabel(): array;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?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\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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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\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']);
|
||||
}
|
||||
}
|
||||
@@ -575,6 +575,47 @@ 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,6 +5,7 @@ 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;
|
||||
@@ -55,6 +56,10 @@ 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
|
||||
@@ -75,6 +80,7 @@ final class EntitiesAreTimestampableBlamableTest extends TestCase
|
||||
Permission::class,
|
||||
Site::class,
|
||||
CategoryType::class,
|
||||
StorageType::class,
|
||||
TvaMode::class,
|
||||
PaymentDelay::class,
|
||||
PaymentType::class,
|
||||
|
||||
Reference in New Issue
Block a user