feat: index Meilisearch customer_contact + sync contacts + onglet NDD
MeilisearchService : - Nouvel index customer_contact (searchable: firstName, lastName, fullName, email, phone, role / filterable: customerId, isBillingEmail) - indexContact(), removeContact(), searchContacts() - serializeContact() avec tous les champs SyncController : - Route POST /admin/sync/contacts : sync tous les CustomerContact dans Meilisearch (setupIndexes + indexContact par contact) - totalContacts ajouté dans index() via EntityManager Template admin/sync/index.html.twig : - Bloc "Contacts" violet avec compteur et bouton Synchroniser Template admin/clients/show.html.twig : - Nouvel onglet "Noms de domaine" : table des Domain liés au client (fqdn, registrar, Cloudflare, gestion, facturation, expiration) - Expiration colorée : rouge si expiré, jaune si < 30j, gris sinon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\CustomerContact;
|
||||
use App\Entity\StripeWebhookSecret;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\PriceAutomaticRepository;
|
||||
@@ -23,7 +24,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class SyncController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'index')]
|
||||
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository): Response
|
||||
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em): Response
|
||||
{
|
||||
$prices = $priceRepository->findAll();
|
||||
$stripeSynced = 0;
|
||||
@@ -58,6 +59,7 @@ class SyncController extends AbstractController
|
||||
'stripeNotSynced' => $stripeNotSynced,
|
||||
'customersSynced' => $customersSynced,
|
||||
'customersNotSynced' => $customersNotSynced,
|
||||
'totalContacts' => $em->getRepository(CustomerContact::class)->count([]),
|
||||
'webhookSecrets' => $webhookSecrets,
|
||||
]);
|
||||
}
|
||||
@@ -79,6 +81,23 @@ class SyncController extends AbstractController
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
#[Route('/contacts', name: 'contacts', methods: ['POST'])]
|
||||
public function syncContacts(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
try {
|
||||
$meilisearch->setupIndexes();
|
||||
$contacts = $em->getRepository(CustomerContact::class)->findAll();
|
||||
foreach ($contacts as $contact) {
|
||||
$meilisearch->indexContact($contact);
|
||||
}
|
||||
$this->addFlash('success', \count($contacts).' contact(s) synchronise(s) dans Meilisearch.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('error', 'Erreur sync contacts : '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
#[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])]
|
||||
public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerContact;
|
||||
use App\Entity\PriceAutomatic;
|
||||
use App\Entity\Revendeur;
|
||||
use Meilisearch\Client;
|
||||
@@ -129,6 +130,42 @@ class MeilisearchService
|
||||
}
|
||||
}
|
||||
|
||||
public function indexContact(CustomerContact $contact): void
|
||||
{
|
||||
try {
|
||||
$this->client->index('customer_contact')->addDocuments([
|
||||
$this->serializeContact($contact),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Meilisearch: Failed to index contact '.$contact->getId().': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function removeContact(int $contactId): void
|
||||
{
|
||||
try {
|
||||
$this->client->index('customer_contact')->deleteDocument($contactId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Meilisearch: Failed to remove contact '.$contactId.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function searchContacts(string $query, int $limit = 20): array
|
||||
{
|
||||
try {
|
||||
$results = $this->client->index('customer_contact')->search($query, ['limit' => $limit]);
|
||||
|
||||
return $results->getHits();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Meilisearch: search contacts error: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function setupIndexes(): void
|
||||
{
|
||||
try {
|
||||
@@ -166,6 +203,18 @@ class MeilisearchService
|
||||
$this->client->index('price_auto')->updateFilterableAttributes([
|
||||
'type', 'period',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->client->createIndex('customer_contact', ['primaryKey' => 'id']);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Meilisearch: setupIndexes (customer_contact) - '.$e->getMessage());
|
||||
}
|
||||
$this->client->index('customer_contact')->updateSearchableAttributes([
|
||||
'firstName', 'lastName', 'fullName', 'email', 'phone', 'role',
|
||||
]);
|
||||
$this->client->index('customer_contact')->updateFilterableAttributes([
|
||||
'customerId', 'isBillingEmail',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,4 +288,22 @@ class MeilisearchService
|
||||
'isActive' => $revendeur->isActive(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeContact(CustomerContact $contact): array
|
||||
{
|
||||
return [
|
||||
'id' => $contact->getId(),
|
||||
'customerId' => $contact->getCustomer()->getId(),
|
||||
'firstName' => $contact->getFirstName(),
|
||||
'lastName' => $contact->getLastName(),
|
||||
'fullName' => $contact->getFullName(),
|
||||
'email' => $contact->getEmail(),
|
||||
'phone' => $contact->getPhone(),
|
||||
'role' => $contact->getRole(),
|
||||
'isBillingEmail' => $contact->isBillingEmail(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
'devis': 'Devis',
|
||||
'impayes': 'Impayes',
|
||||
'echeancier': 'Echeancier',
|
||||
'ndd': 'Noms de domaine',
|
||||
'esyflex': 'EsyFlex',
|
||||
'sites': 'Sites Internet',
|
||||
'services': 'Services'
|
||||
@@ -254,6 +255,58 @@
|
||||
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contact.</div>
|
||||
{% endif %}
|
||||
|
||||
{# Tab: Noms de domaine #}
|
||||
{% elseif tab == 'ndd' %}
|
||||
{% if domains|length > 0 %}
|
||||
<div class="glass overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="glass-dark text-white">
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Domaine</th>
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Registrar</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Cloudflare</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Gestion</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Facturation</th>
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Expiration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for domain in domains %}
|
||||
<tr class="border-b border-white/20 hover:bg-white/50">
|
||||
<td class="px-4 py-3 font-bold font-mono">{{ domain.fqdn }}</td>
|
||||
<td class="px-4 py-3 text-xs">{{ domain.registrar ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if domain.zoneIdCloudflare %}
|
||||
<span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">{{ domain.zoneCloudflare ?? 'Lie' }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-300">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if domain.isGestion %}<span class="text-green-600 font-bold">✓</span>{% else %}<span class="text-gray-300">✗</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if domain.isBilling %}<span class="text-green-600 font-bold">✓</span>{% else %}<span class="text-gray-300">✗</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
{% if domain.expiredAt %}
|
||||
<span class="{{ domain.isExpired ? 'text-red-600 font-bold' : (domain.isExpiringSoon ? 'text-yellow-600 font-bold' : 'text-gray-500') }}">
|
||||
{{ domain.expiredAt|date('d/m/Y') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-gray-300">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-400">{{ domains|length }} domaine(s)</p>
|
||||
{% else %}
|
||||
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun nom de domaine.</div>
|
||||
{% endif %}
|
||||
|
||||
{# Tabs placeholder #}
|
||||
{% else %}
|
||||
<div class="glass p-12 text-center">
|
||||
|
||||
@@ -52,6 +52,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sync contacts #}
|
||||
<div class="glass p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-violet-100 border-2 border-violet-600 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-bold uppercase text-sm">Contacts</h2>
|
||||
<p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_contact</strong></p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ totalContacts }} contact(s) en base</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ path('app_admin_sync_contacts') }}">
|
||||
<button type="submit" class="px-4 py-2 btn-glass text-violet-600 font-bold uppercase text-[10px] tracking-wider">
|
||||
Synchroniser
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sync revendeurs #}
|
||||
<div class="glass p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
|
||||
@@ -139,8 +139,12 @@ class AdminControllersTest extends TestCase
|
||||
$prepo = $this->createStub(PriceAutomaticRepository::class);
|
||||
$srepo = $this->createStub(StripeWebhookSecretRepository::class);
|
||||
$srepo->method('findAll')->willReturn([]);
|
||||
$contactRepo = $this->createStub(\Doctrine\ORM\EntityRepository::class);
|
||||
$contactRepo->method('count')->willReturn(0);
|
||||
$em = $this->createStub(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturn($contactRepo);
|
||||
$controller = $this->createMockController(SyncController::class);
|
||||
$response = $controller->index($crepo, $rrepo, $prepo, $srepo);
|
||||
$response = $controller->index($crepo, $rrepo, $prepo, $srepo, $em);
|
||||
$this->assertInstanceOf(Response::class, $response);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,12 @@ class SyncControllerTest extends TestCase
|
||||
$controller = new SyncController();
|
||||
$controller->setContainer($this->createContainer());
|
||||
|
||||
$response = $controller->index($customerRepo, $revendeurRepo, $priceRepo, $secretRepo);
|
||||
$contactRepo = $this->createStub(\Doctrine\ORM\EntityRepository::class);
|
||||
$contactRepo->method('count')->willReturn(0);
|
||||
$em = $this->createStub(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->method('getRepository')->willReturn($contactRepo);
|
||||
|
||||
$response = $controller->index($customerRepo, $revendeurRepo, $priceRepo, $secretRepo, $em);
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user