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>
296 lines
11 KiB
PHP
296 lines
11 KiB
PHP
<?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));
|
|
}
|
|
}
|