feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N) - Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write - Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone - Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories - MAJ MCP tools / MachineStructureController / audit subscriber / tests - Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
295
src/Command/ImportFournisseursCommand.php
Normal file
295
src/Command/ImportFournisseursCommand.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Constructeur;
|
||||
use App\Entity\ConstructeurCategorie;
|
||||
use App\Entity\ConstructeurTelephone;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use SplObjectStorage;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Importe un référentiel de fournisseurs depuis un fichier JSON de la forme
|
||||
* {"count": N, "data": [{"reference": "...", "name": "...", "categoriesStr": "a, b", "organizationsStr": "...", "phone": "..."}, ...]}.
|
||||
*
|
||||
* Règles : on garde l'existant. Si un fournisseur du fichier porte le même nom (insensible à la casse/aux espaces)
|
||||
* qu'un fournisseur déjà en base, on le complète sans changer son id : on n'ajoute que les catégories et les
|
||||
* téléphones manquants, on n'écrase ni ne supprime jamais rien.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:import-fournisseurs',
|
||||
description: 'Importe/complète les fournisseurs depuis un fichier JSON (customer.json par défaut). Dry-run par défaut : utiliser --force pour écrire.',
|
||||
)]
|
||||
class ImportFournisseursCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('file', InputArgument::OPTIONAL, 'Chemin du fichier JSON', 'customer.json')
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Écrit réellement en base (sinon dry-run)')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Ne traiter que les N premières entrées (debug)')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$write = (bool) $input->getOption('force');
|
||||
$limit = null !== $input->getOption('limit') ? max(0, (int) $input->getOption('limit')) : null;
|
||||
|
||||
$path = (string) $input->getArgument('file');
|
||||
if (!str_starts_with($path, '/')) {
|
||||
$path = rtrim($this->projectDir, '/').'/'.$path;
|
||||
}
|
||||
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
$io->error(sprintf('Fichier introuvable ou illisible : %s', $path));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
$decoded = json_decode((string) $raw, true);
|
||||
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
|
||||
$io->error('JSON invalide : la clé "data" (tableau) est attendue.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
/** @var array<int, array<string, mixed>> $rows */
|
||||
$rows = $decoded['data'];
|
||||
if (null !== $limit) {
|
||||
$rows = array_slice($rows, 0, $limit);
|
||||
}
|
||||
|
||||
$io->title('Import fournisseurs');
|
||||
$io->writeln(sprintf('Fichier : <info>%s</info>', $path));
|
||||
$io->writeln(sprintf('Entrées : <info>%d</info>', count($rows)));
|
||||
$io->writeln($write ? '<comment>Mode écriture (--force)</comment>' : '<comment>Mode dry-run — aucune écriture. Ajouter --force pour appliquer.</comment>');
|
||||
$io->newLine();
|
||||
|
||||
// --- Chargement des référentiels existants ---------------------------------
|
||||
/** @var array<string, Constructeur> $constructeursByName */
|
||||
$constructeursByName = [];
|
||||
foreach ($this->em->getRepository(Constructeur::class)->findAll() as $c) {
|
||||
$constructeursByName[$this->normalizeKey((string) $c->getName())] = $c;
|
||||
}
|
||||
|
||||
/** @var array<string, ConstructeurCategorie> $categoriesByName */
|
||||
$categoriesByName = [];
|
||||
foreach ($this->em->getRepository(ConstructeurCategorie::class)->findAll() as $cat) {
|
||||
$categoriesByName[$this->normalizeKey((string) $cat->getName())] = $cat;
|
||||
}
|
||||
|
||||
// numéros et liens catégorie déjà présents, indexés par objet Constructeur
|
||||
$seenNumeros = new SplObjectStorage(); // Constructeur => array<string,true> (clé = numéro normalisé)
|
||||
$seenCatLinks = new SplObjectStorage(); // Constructeur => array<string,true> (clé = nom catégorie normalisé)
|
||||
|
||||
// pré-remplissage pour les fournisseurs existants
|
||||
$existingTel = $this->em->getRepository(ConstructeurTelephone::class)->findAll();
|
||||
foreach ($existingTel as $tel) {
|
||||
$owner = $tel->getConstructeur();
|
||||
if (null === $owner) {
|
||||
continue;
|
||||
}
|
||||
$map = $seenNumeros[$owner] ?? [];
|
||||
$map[$this->normalizeKey((string) $tel->getNumero())] = true;
|
||||
$seenNumeros[$owner] = $map;
|
||||
}
|
||||
|
||||
/** @var array<int, array{cname: string, catname: string}> $catLinkPairs */
|
||||
$catLinkPairs = $this->em->createQuery(
|
||||
'SELECT c.name AS cname, cat.name AS catname FROM '.Constructeur::class.' c JOIN c.categories cat'
|
||||
)->getArrayResult();
|
||||
foreach ($catLinkPairs as $pair) {
|
||||
$cKey = $this->normalizeKey((string) $pair['cname']);
|
||||
$catKey = $this->normalizeKey((string) $pair['catname']);
|
||||
$owner = $constructeursByName[$cKey] ?? null;
|
||||
if (null === $owner) {
|
||||
continue;
|
||||
}
|
||||
$map = $seenCatLinks[$owner] ?? [];
|
||||
$map[$catKey] = true;
|
||||
$seenCatLinks[$owner] = $map;
|
||||
}
|
||||
|
||||
// --- Traitement ------------------------------------------------------------
|
||||
$created = 0;
|
||||
$matched = 0;
|
||||
$phonesAdded = 0;
|
||||
$categoriesCreated = 0;
|
||||
$catLinksAdded = 0;
|
||||
$skippedNoName = 0;
|
||||
$tooLong = [];
|
||||
|
||||
$i = 0;
|
||||
foreach ($rows as $row) {
|
||||
++$i;
|
||||
$name = trim((string) ($row['name'] ?? $row['reference'] ?? ''));
|
||||
if ('' === $name) {
|
||||
++$skippedNoName;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (mb_strlen($name) > 255) {
|
||||
$tooLong[] = $name;
|
||||
$name = mb_substr($name, 0, 255);
|
||||
}
|
||||
|
||||
$key = $this->normalizeKey($name);
|
||||
if (isset($constructeursByName[$key])) {
|
||||
$constructeur = $constructeursByName[$key];
|
||||
++$matched;
|
||||
} else {
|
||||
$constructeur = new Constructeur()->setName($name);
|
||||
if ($write) {
|
||||
$this->em->persist($constructeur);
|
||||
}
|
||||
$constructeursByName[$key] = $constructeur;
|
||||
++$created;
|
||||
}
|
||||
|
||||
// --- téléphones ---
|
||||
foreach ($this->splitPhones((string) ($row['phone'] ?? '')) as $numero) {
|
||||
$numero = mb_substr($numero, 0, 50);
|
||||
$nKey = $this->normalizeKey($numero);
|
||||
$map = $seenNumeros[$constructeur] ?? [];
|
||||
if (isset($map[$nKey])) {
|
||||
continue;
|
||||
}
|
||||
$tel = new ConstructeurTelephone()->setNumero($numero);
|
||||
$constructeur->addTelephone($tel);
|
||||
if ($write) {
|
||||
$this->em->persist($tel);
|
||||
}
|
||||
$map[$nKey] = true;
|
||||
$seenNumeros[$constructeur] = $map;
|
||||
++$phonesAdded;
|
||||
}
|
||||
|
||||
// --- catégories ---
|
||||
foreach ($this->splitCategories((string) ($row['categoriesStr'] ?? '')) as $catName) {
|
||||
$catName = mb_substr($catName, 0, 255);
|
||||
$catKey = $this->normalizeKey($catName);
|
||||
if (isset($categoriesByName[$catKey])) {
|
||||
$categorie = $categoriesByName[$catKey];
|
||||
} else {
|
||||
$categorie = new ConstructeurCategorie()->setName($catName);
|
||||
if ($write) {
|
||||
$this->em->persist($categorie);
|
||||
}
|
||||
$categoriesByName[$catKey] = $categorie;
|
||||
++$categoriesCreated;
|
||||
}
|
||||
|
||||
$linkMap = $seenCatLinks[$constructeur] ?? [];
|
||||
if (isset($linkMap[$catKey])) {
|
||||
continue;
|
||||
}
|
||||
$constructeur->addCategory($categorie);
|
||||
$linkMap[$catKey] = true;
|
||||
$seenCatLinks[$constructeur] = $linkMap;
|
||||
++$catLinksAdded;
|
||||
}
|
||||
|
||||
if ($write && 0 === $i % 200) {
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
if ($write) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// --- Rapport ---------------------------------------------------------------
|
||||
$io->section('Résultat');
|
||||
$io->table(
|
||||
['Action', 'Nombre'],
|
||||
[
|
||||
['Fournisseurs créés', $created],
|
||||
['Fournisseurs déjà en base (complétés si besoin)', $matched],
|
||||
['Téléphones ajoutés', $phonesAdded],
|
||||
['Catégories créées', $categoriesCreated],
|
||||
['Liens fournisseur↔catégorie ajoutés', $catLinksAdded],
|
||||
['Entrées ignorées (sans nom)', $skippedNoName],
|
||||
['Noms tronqués (>255)', count($tooLong)],
|
||||
]
|
||||
);
|
||||
|
||||
if ($tooLong) {
|
||||
$io->warning(sprintf('%d nom(s) dépassaient 255 caractères et ont été tronqués.', count($tooLong)));
|
||||
}
|
||||
|
||||
if ($write) {
|
||||
$io->success('Import terminé.');
|
||||
} else {
|
||||
$io->note('Dry-run : rien n\'a été écrit. Relancer avec --force pour appliquer.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitPhones(string $value): array
|
||||
{
|
||||
$parts = preg_split('#[/;\n\r]+#', $value) ?: [];
|
||||
$out = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = trim($p);
|
||||
if ('' !== $p) {
|
||||
$out[] = $p;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function splitCategories(string $value): array
|
||||
{
|
||||
$parts = explode(',', $value);
|
||||
$out = [];
|
||||
$seen = [];
|
||||
foreach ($parts as $p) {
|
||||
$p = trim($p);
|
||||
if ('' === $p) {
|
||||
continue;
|
||||
}
|
||||
$k = $this->normalizeKey($p);
|
||||
if (isset($seen[$k])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$k] = true;
|
||||
$out[] = $p;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function normalizeKey(string $value): string
|
||||
{
|
||||
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $value) ?? $value));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\Constructeur;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
@@ -872,7 +873,7 @@ class MachineStructureController extends AbstractController
|
||||
'id' => $link->getConstructeur()->getId(),
|
||||
'name' => $link->getConstructeur()->getName(),
|
||||
'email' => $link->getConstructeur()->getEmail(),
|
||||
'phone' => $link->getConstructeur()->getPhone(),
|
||||
'phone' => $this->constructeurPhone($link->getConstructeur()),
|
||||
],
|
||||
'supplierReference' => $link->getSupplierReference(),
|
||||
];
|
||||
@@ -881,6 +882,13 @@ class MachineStructureController extends AbstractController
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function constructeurPhone(Constructeur $constructeur): ?string
|
||||
{
|
||||
$first = $constructeur->getTelephones()->first();
|
||||
|
||||
return false !== $first ? $first->getNumero() : null;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
@@ -19,6 +19,7 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
|
||||
@@ -36,7 +37,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
paginationMaximumItemsPerPage: 200,
|
||||
normalizationContext: ['groups' => ['constructeur:read']],
|
||||
denormalizationContext: ['groups' => ['constructeur:write']]
|
||||
)]
|
||||
class Constructeur
|
||||
{
|
||||
@@ -44,24 +47,43 @@ class Constructeur
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['constructeur:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
||||
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['constructeur:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
#[Groups(['constructeur:read'])]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ConstructeurTelephone>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ConstructeurTelephone::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||
private Collection $telephones;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ConstructeurCategorie>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: ConstructeurCategorie::class, inversedBy: 'constructeurs')]
|
||||
#[ORM\JoinTable(name: 'constructeur_categories')]
|
||||
#[ORM\JoinColumn(name: 'constructeur_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'categorie_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||
private Collection $categories;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineConstructeurLink>
|
||||
*/
|
||||
@@ -94,6 +116,8 @@ class Constructeur
|
||||
$this->composantLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->telephones = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
@@ -120,14 +144,55 @@ class Constructeur
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
/**
|
||||
* @return Collection<int, ConstructeurTelephone>
|
||||
*/
|
||||
public function getTelephones(): Collection
|
||||
{
|
||||
return $this->phone;
|
||||
return $this->telephones;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
public function addTelephone(ConstructeurTelephone $telephone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
if (!$this->telephones->contains($telephone)) {
|
||||
$this->telephones->add($telephone);
|
||||
$telephone->setConstructeur($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeTelephone(ConstructeurTelephone $telephone): static
|
||||
{
|
||||
if ($this->telephones->removeElement($telephone)) {
|
||||
if ($telephone->getConstructeur() === $this) {
|
||||
$telephone->setConstructeur(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ConstructeurCategorie>
|
||||
*/
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(ConstructeurCategorie $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(ConstructeurCategorie $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
92
src/Entity/ConstructeurCategorie.php
Normal file
92
src/Entity/ConstructeurCategorie.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\ConstructeurCategorieRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[UniqueEntity(fields: ['name'], message: 'Une catégorie de fournisseur avec ce nom existe déjà.')]
|
||||
#[ORM\Entity(repositoryClass: ConstructeurCategorieRepository::class)]
|
||||
#[ORM\Table(name: 'constructeur_categorie')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
description: 'Catégories de fournisseurs (ex. organisme de formation, transporteur, agence d\'intérim). Référentiel partagé : une même catégorie peut être rattachée à plusieurs fournisseurs.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 1000,
|
||||
order: ['name' => 'ASC']
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name'])]
|
||||
class ConstructeurCategorie
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['constructeur:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
||||
#[Groups(['constructeur:read'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Constructeur>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Constructeur::class, mappedBy: 'categories')]
|
||||
private Collection $constructeurs;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
109
src/Entity/ConstructeurTelephone.php
Normal file
109
src/Entity/ConstructeurTelephone.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\ConstructeurTelephoneRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ConstructeurTelephoneRepository::class)]
|
||||
#[ORM\Table(name: 'constructeur_telephone')]
|
||||
#[ORM\Index(name: 'idx_constructeur_telephone_constructeur', columns: ['constructeurid'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
description: 'Numéro de téléphone rattaché à un fournisseur. Un fournisseur peut en avoir plusieurs (standard, mobile, comptabilité…).',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['constructeur' => 'exact'])]
|
||||
class ConstructeurTelephone
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['constructeur:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'telephones')]
|
||||
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Constructeur $constructeur = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 50)]
|
||||
#[Assert\NotBlank(message: 'Le numéro de téléphone est obligatoire.')]
|
||||
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||
private ?string $numero = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
|
||||
#[Groups(['constructeur:read', 'constructeur:write'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getConstructeur(): ?Constructeur
|
||||
{
|
||||
return $this->constructeur;
|
||||
}
|
||||
|
||||
public function setConstructeur(?Constructeur $constructeur): static
|
||||
{
|
||||
$this->constructeur = $constructeur;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNumero(): ?string
|
||||
{
|
||||
return $this->numero;
|
||||
}
|
||||
|
||||
public function setNumero(string $numero): static
|
||||
{
|
||||
$this->numero = $numero;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): static
|
||||
{
|
||||
$this->label = $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\Constructeur;
|
||||
use App\Entity\ConstructeurCategorie;
|
||||
use App\Entity\ConstructeurTelephone;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
#[AsDoctrineListener(event: Events::onFlush)]
|
||||
@@ -23,11 +26,21 @@ final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
|
||||
|
||||
protected function snapshotEntity(object $entity): array
|
||||
{
|
||||
$telephones = $this->safeGet($entity, 'getTelephones');
|
||||
$categories = $this->safeGet($entity, 'getCategories');
|
||||
|
||||
return [
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'email' => $this->safeGet($entity, 'getEmail'),
|
||||
'phone' => $this->safeGet($entity, 'getPhone'),
|
||||
'id' => $entity->getId(),
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'email' => $this->safeGet($entity, 'getEmail'),
|
||||
'telephones' => $telephones instanceof Collection ? array_values(array_map(
|
||||
static fn (ConstructeurTelephone $t): array => ['numero' => $t->getNumero(), 'label' => $t->getLabel()],
|
||||
$telephones->toArray(),
|
||||
)) : [],
|
||||
'categories' => $categories instanceof Collection ? array_values(array_filter(array_map(
|
||||
static fn (ConstructeurCategorie $c): ?string => $c->getName(),
|
||||
$categories->toArray(),
|
||||
))) : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Entity\Constructeur;
|
||||
use App\Entity\ConstructeurTelephone;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
@@ -34,7 +35,12 @@ class CreateConstructeurTool
|
||||
$constructeur = new Constructeur();
|
||||
$constructeur->setName($name);
|
||||
$constructeur->setEmail('' !== $email ? $email : null);
|
||||
$constructeur->setPhone('' !== $phone ? $phone : null);
|
||||
|
||||
if ('' !== $phone) {
|
||||
$telephone = new ConstructeurTelephone();
|
||||
$telephone->setNumero($phone);
|
||||
$constructeur->addTelephone($telephone);
|
||||
}
|
||||
|
||||
$this->em->persist($constructeur);
|
||||
$this->em->flush();
|
||||
|
||||
@@ -29,13 +29,23 @@ class GetConstructeurTool
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
$telephones = array_map(
|
||||
static fn ($t): array => ['id' => $t->getId(), 'numero' => $t->getNumero(), 'label' => $t->getLabel()],
|
||||
$constructeur->getTelephones()->toArray(),
|
||||
);
|
||||
$categories = array_values(array_filter(array_map(
|
||||
static fn ($c): ?string => $c->getName(),
|
||||
$constructeur->getCategories()->toArray(),
|
||||
)));
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'telephones' => array_values($telephones),
|
||||
'categories' => $categories,
|
||||
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class ListConstructeursTool
|
||||
;
|
||||
|
||||
$qb = $this->constructeurs->createQueryBuilder('c')
|
||||
->select('c.id', 'c.name', 'c.email', 'c.phone')
|
||||
->select('c.id', 'c.name', 'c.email')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Entity\ConstructeurTelephone;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -13,7 +14,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_constructeur',
|
||||
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
|
||||
description: 'Update an existing constructeur. Only provided fields are changed. A non-empty "phone" is added as an additional phone number if not already present (existing numbers are never removed). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateConstructeurTool
|
||||
{
|
||||
@@ -45,8 +46,20 @@ class UpdateConstructeurTool
|
||||
if (null !== $email) {
|
||||
$constructeur->setEmail($email);
|
||||
}
|
||||
if (null !== $phone) {
|
||||
$constructeur->setPhone($phone);
|
||||
if (null !== $phone && '' !== $phone) {
|
||||
$alreadyPresent = false;
|
||||
foreach ($constructeur->getTelephones() as $existing) {
|
||||
if ($existing->getNumero() === $phone) {
|
||||
$alreadyPresent = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$alreadyPresent) {
|
||||
$telephone = new ConstructeurTelephone();
|
||||
$telephone->setNumero($phone);
|
||||
$constructeur->addTelephone($telephone);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\Constructeur;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
@@ -364,7 +365,7 @@ class MachineStructureTool
|
||||
'id' => $link->getConstructeur()->getId(),
|
||||
'name' => $link->getConstructeur()->getName(),
|
||||
'email' => $link->getConstructeur()->getEmail(),
|
||||
'phone' => $link->getConstructeur()->getPhone(),
|
||||
'phone' => $this->constructeurPhone($link->getConstructeur()),
|
||||
],
|
||||
'supplierReference' => $link->getSupplierReference(),
|
||||
];
|
||||
@@ -373,6 +374,13 @@ class MachineStructureTool
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function constructeurPhone(Constructeur $constructeur): ?string
|
||||
{
|
||||
$first = $constructeur->getTelephones()->first();
|
||||
|
||||
return false !== $first ? $first->getNumero() : null;
|
||||
}
|
||||
|
||||
private function normalizeCustomFields(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
|
||||
20
src/Repository/ConstructeurCategorieRepository.php
Normal file
20
src/Repository/ConstructeurCategorieRepository.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ConstructeurCategorie;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ConstructeurCategorie>
|
||||
*/
|
||||
class ConstructeurCategorieRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ConstructeurCategorie::class);
|
||||
}
|
||||
}
|
||||
20
src/Repository/ConstructeurTelephoneRepository.php
Normal file
20
src/Repository/ConstructeurTelephoneRepository.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ConstructeurTelephone;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ConstructeurTelephone>
|
||||
*/
|
||||
class ConstructeurTelephoneRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ConstructeurTelephone::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user