Files
crm_ecosplay/src/Controller/Admin/SyncController.php
Serreau Jovann 8b35e2b6d2 feat: comptabilite + prestataires + rapport financier + stats dynamiques
Comptabilite (Super Admin) :
- ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE
  (journal ventes, grand livre, FEC, balance agee, reglements,
  commissions Stripe 1.5%+0.25E, couts services)
- Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli,
  tableau pagine, champ signature DocuSeal
- Signature electronique DocuSeal + callback + envoi email signe
  avec template dedie (compta_export_signed.html.twig)
- Rapport financier public (RapportFinancierPdf) : recettes par
  service, depenses (Stripe, infra, prestataires), bilan excedent/deficit
- Codes comptables clients EC-XXXX (plus de 411xxx)

Prestataires (Super Admin) :
- Entite Prestataire (raisonSociale, siret, email, phone, adresse)
- Entite FacturePrestataire (numFacture, montantHt, montantTtc,
  year, month, isPaid, PDF via Vich)
- CRUD complet avec recherche SIRET via proxy API data.gouv.fr
- Commande cron app:reminder:factures-prestataire (5 du mois)
- Factures prestataires integrees dans export couts services
- Sidebar Super Admin : entree Prestataires + Comptabilite

Stats (/admin/stats) :
- Cout prestataire dynamique depuis FacturePrestataire
- Fusion Infra + Prestataire en "Cout de fonctionnement"
- Commission Stripe corrigee (1.5% + 0.25E par transaction)

Divers :
- DocuSealService::sendComptaForSignature() + getApi()
- Customer::generateCodeComptable() format EC-XXXX-XXXXX
- Protection double prefixe EC- a la creation client
- Bouton regenerer PDF cache quand advert state=accepted
- Modals sans script inline (data-modal-open/close dans app.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00

399 lines
16 KiB
PHP

<?php
namespace App\Controller\Admin;
use App\Entity\Advert;
use App\Entity\CustomerContact;
use App\Entity\Devis;
use App\Entity\Domain;
use App\Entity\Facture;
use App\Entity\StripeWebhookSecret;
use App\Entity\Website;
use App\Repository\CustomerRepository;
use App\Repository\PriceAutomaticRepository;
use App\Repository\RevendeurRepository;
use App\Repository\StripeWebhookSecretRepository;
use Doctrine\ORM\EntityManagerInterface;
use App\Service\MeilisearchService;
use App\Service\StripePriceService;
use App\Service\StripeWebhookService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/admin/sync', name: 'app_admin_sync_')]
#[IsGranted('ROLE_ROOT')]
class SyncController extends AbstractController
{
#[Route('', name: 'index')]
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
$prices = $priceRepository->findAll();
$stripeSynced = 0;
$stripeNotSynced = 0;
foreach ($prices as $price) {
if (null !== $price->getStripeId() && '' !== $price->getStripeId()) {
++$stripeSynced;
} else {
++$stripeNotSynced;
}
}
$webhookSecrets = $secretRepository->findAll();
$customers = $customerRepository->findAll();
$customersSynced = 0;
$customersNotSynced = 0;
foreach ($customers as $c) {
if (null !== $c->getStripeCustomerId() && '' !== $c->getStripeCustomerId()) {
++$customersSynced;
} else {
++$customersNotSynced;
}
}
return $this->render('admin/sync/index.html.twig', [
'totalCustomers' => \count($customers),
'totalRevendeurs' => $revendeurRepository->count([]),
'totalPrices' => \count($prices),
'stripeSynced' => $stripeSynced,
'stripeNotSynced' => $stripeNotSynced,
'customersSynced' => $customersSynced,
'customersNotSynced' => $customersNotSynced,
'totalContacts' => $em->getRepository(CustomerContact::class)->count([]),
'totalDomains' => $em->getRepository(Domain::class)->count([]),
'totalWebsites' => $em->getRepository(Website::class)->count([]),
'totalDevis' => $em->getRepository(Devis::class)->count([]),
'totalAdverts' => $em->getRepository(Advert::class)->count([]),
'totalFactures' => $em->getRepository(Facture::class)->count([]),
'webhookSecrets' => $webhookSecrets,
'msCustomers' => $meilisearch->getIndexCount('customer'),
'msResellers' => $meilisearch->getIndexCount('reseller'),
'msPrices' => $meilisearch->getIndexCount('price_auto'),
'msContacts' => $meilisearch->getIndexCount('customer_contact'),
'msDomains' => $meilisearch->getIndexCount('customer_ndd'),
'msWebsites' => $meilisearch->getIndexCount('customer_website'),
'msDevis' => $meilisearch->getIndexCount('customer_devis'),
'msAdverts' => $meilisearch->getIndexCount('customer_advert'),
'msFactures' => $meilisearch->getIndexCount('customer_facture'),
]);
}
#[Route('/purge-indexes', name: 'purge_indexes', methods: ['POST'])]
public function purgeIndexes(MeilisearchService $meilisearch): Response
{
$meilisearch->purgeAllIndexes();
$this->addFlash('success', 'Tous les index Meilisearch ont ete purges.');
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/customers', name: 'customers', methods: ['POST'])]
public function syncCustomers(CustomerRepository $customerRepository, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$customers = $customerRepository->findAll();
foreach ($customers as $customer) {
$meilisearch->indexCustomer($customer);
}
$this->addFlash('success', \count($customers).' client(s) synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync clients : '.$e->getMessage());
}
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('/domains', name: 'domains', methods: ['POST'])]
public function syncDomains(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$domains = $em->getRepository(Domain::class)->findAll();
foreach ($domains as $domain) {
$meilisearch->indexDomain($domain);
}
$this->addFlash('success', \count($domains).' domaine(s) synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync domaines : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/websites', name: 'websites', methods: ['POST'])]
public function syncWebsites(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$websites = $em->getRepository(Website::class)->findAll();
foreach ($websites as $website) {
$meilisearch->indexWebsite($website);
}
$this->addFlash('success', \count($websites).' site(s) synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync sites : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/devis', name: 'devis', methods: ['POST'])]
public function syncDevis(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$items = $em->getRepository(Devis::class)->findAll();
foreach ($items as $item) {
$meilisearch->indexDevis($item);
}
$this->addFlash('success', \count($items).' devis synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync devis : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/adverts', name: 'adverts', methods: ['POST'])]
public function syncAdverts(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$items = $em->getRepository(Advert::class)->findAll();
foreach ($items as $item) {
$meilisearch->indexAdvert($item);
}
$this->addFlash('success', \count($items).' avis de paiement synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync avis : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/factures', name: 'factures', methods: ['POST'])]
public function syncFactures(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$items = $em->getRepository(Facture::class)->findAll();
foreach ($items as $item) {
$meilisearch->indexFacture($item);
}
$this->addFlash('success', \count($items).' facture(s) synchronisee(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync factures : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])]
public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$revendeurs = $revendeurRepository->findAll();
foreach ($revendeurs as $revendeur) {
$meilisearch->indexRevendeur($revendeur);
}
$this->addFlash('success', \count($revendeurs).' revendeur(s) synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync revendeurs : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/prices', name: 'prices', methods: ['POST'])]
public function syncPrices(PriceAutomaticRepository $priceRepository, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$prices = $priceRepository->findAll();
foreach ($prices as $price) {
$meilisearch->indexPrice($price);
}
$this->addFlash('success', \count($prices).' tarif(s) synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync tarifs : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/stripe/webhooks', name: 'stripe_webhooks', methods: ['POST'])]
public function syncStripeWebhooks(
StripeWebhookService $webhookService,
StripeWebhookSecretRepository $secretRepository,
EntityManagerInterface $em,
#[Autowire(env: 'WEBHOOK_BASE_URL')] string $webhookBaseUrl,
): Response {
if ('' === $webhookBaseUrl) {
$this->addFlash('error', 'WEBHOOK_BASE_URL non configuree dans .env.local');
return $this->redirectToRoute('app_admin_sync_index');
}
$result = $webhookService->createAllWebhooks(rtrim($webhookBaseUrl, '/'));
$typeMap = [
'Main Light' => StripeWebhookSecret::TYPE_MAIN_LIGHT,
'Main Instant' => StripeWebhookSecret::TYPE_MAIN_INSTANT,
'Connect Light' => StripeWebhookSecret::TYPE_CONNECT_LIGHT,
'Connect Instant' => StripeWebhookSecret::TYPE_CONNECT_INSTANT,
];
foreach ($result['created'] as $wh) {
if ('exists' === $wh['status']) {
$this->addFlash('success', $wh['type'].' : deja configure ('.$wh['id'].')');
} else {
$this->addFlash('success', $wh['type'].' : cree ('.$wh['id'].')');
if (isset($wh['secret'], $typeMap[$wh['type']])) {
$dbType = $typeMap[$wh['type']];
$existing = $secretRepository->findByType($dbType);
if (null !== $existing) {
$existing->setSecret($wh['secret']);
$existing->setEndpointId($wh['id']);
} else {
$entity = new StripeWebhookSecret($dbType, $wh['secret'], $wh['id']);
$em->persist($entity);
}
}
}
}
$em->flush();
foreach ($result['errors'] as $error) {
$this->addFlash('error', 'Stripe Webhook : '.$error);
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/stripe/prices', name: 'stripe_prices', methods: ['POST'])]
public function syncStripePrices(StripePriceService $stripePriceService): Response
{
$result = $stripePriceService->syncAll();
if ([] === $result['errors']) {
$this->addFlash('success', $result['synced'].' tarif(s) synchronise(s) avec Stripe.');
} else {
$this->addFlash('success', $result['synced'].' tarif(s) synchronise(s) avec Stripe.');
foreach ($result['errors'] as $error) {
$this->addFlash('error', 'Stripe : '.$error);
}
}
return $this->redirectToRoute('app_admin_sync_index');
}
/** @codeCoverageIgnore */
#[Route('/stripe/customers', name: 'stripe_customers', methods: ['POST'])]
public function syncStripeCustomers(
CustomerRepository $customerRepository,
EntityManagerInterface $em,
#[Autowire(env: 'STRIPE_SK')] string $stripeSecretKey,
): Response {
if ('' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) {
$this->addFlash('error', 'STRIPE_SK non configuree.');
return $this->redirectToRoute('app_admin_sync_index');
}
\Stripe\Stripe::setApiKey($stripeSecretKey);
$customers = $customerRepository->findAll();
$created = 0;
$updated = 0;
$errors = 0;
foreach ($customers as $customer) {
try {
$params = [
'email' => $customer->getEmail(),
'name' => $customer->getRaisonSociale() ?? $customer->getFullName(),
'phone' => $customer->getPhone(),
'metadata' => [
'crm_user_id' => (string) $customer->getUser()->getId(),
'siret' => $customer->getSiret() ?? '',
'code_comptable' => $customer->getCodeComptable() ?? '',
],
];
if (null !== $customer->getStripeCustomerId() && '' !== $customer->getStripeCustomerId()) {
\Stripe\Customer::update($customer->getStripeCustomerId(), $params);
++$updated;
} else {
$stripeCustomer = \Stripe\Customer::create($params);
$customer->setStripeCustomerId($stripeCustomer->id);
++$created;
}
} catch (\Throwable $e) {
$this->addFlash('error', 'Stripe client '.$customer->getEmail().' : '.$e->getMessage());
++$errors;
}
}
$em->flush();
$this->addFlash('success', $created.' client(s) cree(s), '.$updated.' mis a jour dans Stripe'.(0 < $errors ? ', '.$errors.' erreur(s)' : '').'.');
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/all', name: 'all', methods: ['POST'])]
public function syncAll(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$customers = $customerRepository->findAll();
foreach ($customers as $customer) {
$meilisearch->indexCustomer($customer);
}
$revendeurs = $revendeurRepository->findAll();
foreach ($revendeurs as $revendeur) {
$meilisearch->indexRevendeur($revendeur);
}
$prices = $priceRepository->findAll();
foreach ($prices as $price) {
$meilisearch->indexPrice($price);
}
$this->addFlash('success', \count($customers).' client(s), '.\count($revendeurs).' revendeur(s) et '.\count($prices).' tarif(s) synchronise(s).');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
}