findBy([], ['createdAt' => 'DESC']); $customersInfo = $this->buildCustomersInfo($customers, $em); return $this->render('admin/clients/index.html.twig', [ 'customers' => $customers, 'customersInfo' => $customersInfo, ]); } #[Route('/create', name: 'create')] public function create( Request $request, CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, EntityManagerInterface $em, MeilisearchService $meilisearch, UserManagementService $userService, LoggerInterface $logger, HttpClientInterface $httpClient, MailerService $mailer, Environment $twig, #[Autowire(env: 'STRIPE_SK')] string $stripeSecretKey, ): Response { if ('POST' !== $request->getMethod()) { return $this->render('admin/clients/create.html.twig', [ 'revendeurs' => $revendeurRepository->findBy(['isActive' => true], ['codeRevendeur' => 'ASC']), ]); } try { $firstName = trim($request->request->getString('firstName')); $lastName = trim($request->request->getString('lastName')); $email = trim($request->request->getString('email')); $user = $userService->createBaseUser($email, $firstName, $lastName, ['ROLE_CUSTOMER']); $customer = new Customer($user); $this->populateCustomerData($request, $customer); $this->geocodeIfNeeded($customer, $httpClient); $this->setupStripeCustomer($customer, $stripeSecretKey); $em->persist($customer); $em->flush(); $this->finalizeStripeMetadata($customer, $user, $stripeSecretKey); $codeComptable = trim($request->request->getString('codeComptable')); if ('' !== $codeComptable) { $customer->setCodeComptable(str_starts_with($codeComptable, 'EC-') ? $codeComptable : 'EC-'.$codeComptable); } else { $customer->setCodeComptable($customerRepository->generateUniqueCodeComptable($customer)); } $em->flush(); try { $meilisearch->indexCustomer($customer); } catch (\Throwable $e) { $logger->error('Meilisearch: impossible d\'indexer le client '.$customer->getId().': '.$e->getMessage()); } $this->ensureDefaultContact($customer, $em); $setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL); $mailer->sendEmail( $user->getEmail(), self::WELCOME_SUBJECT, $twig->render(self::WELCOME_TEMPLATE, [ 'firstName' => $user->getFirstName(), 'email' => $user->getEmail(), 'setPasswordUrl' => $setPasswordUrl, ]), ); $this->addFlash('success', 'Client '.$customer->getFullName().' cree. Email de bienvenue envoye.'); return $this->redirectToRoute('app_admin_clients_index'); } catch (\InvalidArgumentException $e) { $this->addFlash('error', $e->getMessage()); } catch (\Throwable $e) { $this->addFlash('error', 'Erreur : '.$e->getMessage()); } return $this->render('admin/clients/create.html.twig'); } /** * @param list $customers * * @return array */ private function buildCustomersInfo(array $customers, EntityManagerInterface $em): array { $domainRepo = $em->getRepository(Domain::class); $emailRepo = $em->getRepository(\App\Entity\DomainEmail::class); $websiteRepo = $em->getRepository(\App\Entity\Website::class); $info = []; foreach ($customers as $customer) { $domains = $domainRepo->findBy(['customer' => $customer]); $emailCount = 0; foreach ($domains as $domain) { $emailCount += $emailRepo->count(['domain' => $domain]); } $info[$customer->getId()] = [ 'sites' => $websiteRepo->count(['customer' => $customer]), 'domains' => \count($domains), 'emails' => $emailCount, 'esySign' => false, 'esyNewsletter' => false, 'esyMail' => $emailCount > 0, 'unpaid' => 0, ]; } return $info; } /** * @param list $domains * * @return array */ private function buildDomainsInfo(array $domains, EntityManagerInterface $em, EsyMailDnsService $dnsService, bool $checkDns = false): array { $emailRepo = $em->getRepository(\App\Entity\DomainEmail::class); $info = []; foreach ($domains as $domain) { $emailCount = $emailRepo->count(['domain' => $domain]); $configMail = false; $configMailer = false; if ($checkDns) { $configMail = $dnsService->checkDnsEsyMail($domain->getFqdn())['ok']; $configMailer = $dnsService->checkDnsEsyMailer($domain->getFqdn())['ok']; } $info[$domain->getId()] = [ 'esyMail' => $emailCount > 0, 'emailCount' => $emailCount, 'esyMailer' => false, 'configDnsEsyMail' => $configMail, 'configDnsEsyMailer' => $configMailer, ]; } return $info; } private function ensureDefaultContact(Customer $customer, EntityManagerInterface $em): void { $contactRepo = $em->getRepository(\App\Entity\CustomerContact::class); $existing = $contactRepo->findBy(['customer' => $customer]); if ([] !== $existing) { return; } $firstName = $customer->getFirstName(); $lastName = $customer->getLastName(); if (null === $firstName || null === $lastName || '' === $firstName || '' === $lastName) { return; } $contact = new \App\Entity\CustomerContact($customer, $firstName, $lastName); $contact->setEmail($customer->getEmail()); $contact->setPhone($customer->getPhone()); $contact->setRole('Directeur'); $contact->setIsBillingEmail(true); $em->persist($contact); $em->flush(); } private function populateCustomerData(Request $request, Customer $customer): void { $customer->setFirstName(trim($request->request->getString('firstName'))); $customer->setLastName(trim($request->request->getString('lastName'))); $customer->setEmail(trim($request->request->getString('email'))); $customer->setPhone(trim($request->request->getString('phone')) ?: null); $customer->setRaisonSociale(trim($request->request->getString('raisonSociale')) ?: null); $customer->setSiret(trim($request->request->getString('siret')) ?: null); $customer->setRcs(trim($request->request->getString('rcs')) ?: null); $customer->setNumTva(trim($request->request->getString('numTva')) ?: null); $customer->setApe(trim($request->request->getString('ape')) ?: null); $customer->setRna(trim($request->request->getString('rna')) ?: null); $customer->setAddress(trim($request->request->getString('address')) ?: null); $customer->setAddress2(trim($request->request->getString('address2')) ?: null); $customer->setZipCode(trim($request->request->getString('zipCode')) ?: null); $customer->setCity(trim($request->request->getString('city')) ?: null); $customer->setTypeCompany(trim($request->request->getString('typeCompany')) ?: null); $customer->setRevendeurCode(trim($request->request->getString('revendeurCode')) ?: null); $customer->setGeoLat(trim($request->request->getString('geoLat')) ?: null); $customer->setGeoLong(trim($request->request->getString('geoLong')) ?: null); } /** @codeCoverageIgnore */ private function geocodeIfNeeded(Customer $customer, HttpClientInterface $httpClient): void { if (null !== $customer->getGeoLat() || null === $customer->getAddress()) { return; } $q = implode(' ', array_filter([$customer->getAddress(), $customer->getZipCode(), $customer->getCity()])); try { $response = $httpClient->request('GET', 'https://data.geopf.fr/geocodage/search', [ 'query' => ['q' => $q, 'limit' => 1], ]); $data = $response->toArray(); $coords = $data['features'][0]['geometry']['coordinates'] ?? null; if (null !== $coords) { $customer->setGeoLong((string) $coords[0]); $customer->setGeoLat((string) $coords[1]); } } catch (\Throwable) { // Geocoding is best-effort, ignore failures silently } } /** @codeCoverageIgnore */ private function setupStripeCustomer(Customer $customer, string $stripeSecretKey): void { if ('' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) { return; } \Stripe\Stripe::setApiKey($stripeSecretKey); $params = [ 'email' => $customer->getEmail(), 'name' => $customer->getRaisonSociale() ?? $customer->getFullName(), 'phone' => $customer->getPhone(), 'metadata' => [ 'crm_user_id' => 'pending', 'siret' => $customer->getSiret() ?? '', 'code_comptable' => $customer->getCodeComptable() ?? '', ], ]; $address = array_filter([ 'line1' => $customer->getAddress(), 'line2' => $customer->getAddress2(), 'postal_code' => $customer->getZipCode(), 'city' => $customer->getCity(), 'country' => 'FR', ]); if (isset($address['line1'])) { $params['address'] = $address; } if ($customer->getNumTva()) { $params['tax_id_data'] = [['type' => 'eu_vat', 'value' => $customer->getNumTva()]]; } $stripeCustomer = \Stripe\Customer::create($params); $customer->setStripeCustomerId($stripeCustomer->id); } /** @codeCoverageIgnore */ private function finalizeStripeMetadata(Customer $customer, User $user, string $stripeSecretKey): void { if (null === $customer->getStripeCustomerId() || '' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) { return; } \Stripe\Stripe::setApiKey($stripeSecretKey); \Stripe\Customer::update($customer->getStripeCustomerId(), [ 'metadata' => [ 'crm_user_id' => (string) $user->getId(), 'code_comptable' => $customer->getCodeComptable() ?? '', ], ]); } #[Route('/search', name: 'search', methods: ['GET'])] public function search(Request $request, MeilisearchService $meilisearch): JsonResponse { $query = trim($request->query->getString('q')); if ('' === $query) { return new JsonResponse([]); } return new JsonResponse($meilisearch->searchCustomers($query)); } #[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])] public function entrepriseSearch(Request $request, \App\Service\EntrepriseSearchService $searchService): JsonResponse { return $searchService->search(trim($request->query->getString('q'))); } #[Route('/{id}', name: 'show')] public function show(Customer $customer, Request $request, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService, EsyMailDnsService $esyMailDnsService, UserPasswordHasherInterface $passwordHasher, MailerService $mailer, Environment $twig): Response { $tab = $request->query->getString('tab', 'info'); if ('POST' === $request->getMethod()) { if ('info' === $tab) { $this->populateCustomerData($request, $customer); $customer->setUpdatedAt(new \DateTimeImmutable()); $em->flush(); $this->addFlash('success', 'Informations mises a jour.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]); } return match ($tab) { 'contacts' => $this->handleContactForm($request, $customer, $em), 'ndd' => $this->handleDomainForm($request, $customer, $em, $ovhService, $cloudflareService, $dnsCheckService), 'securite' => $this->handleSecurityForm($request, $customer, $em, $passwordHasher, $mailer, $twig), default => $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => $tab]), }; } $this->ensureDefaultContact($customer, $em); $contacts = $em->getRepository(\App\Entity\CustomerContact::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $domains = $em->getRepository(Domain::class)->findBy(['customer' => $customer]); $domainsInfo = $this->buildDomainsInfo($domains, $em, $esyMailDnsService, 'ndd' === $tab); $websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); $trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer); return $this->render('admin/clients/show.html.twig', [ 'customer' => $customer, 'contacts' => $contacts, 'domains' => $domains, 'domainsInfo' => $domainsInfo, 'websites' => $websites, 'devisList' => $devisList, 'advertsList' => $advertsList, 'facturesList' => $facturesList, 'echeancierList' => $echeancierList, 'eflexList' => $eflexList, 'tab' => $tab, 'trustStatus' => $trustStatus, ]); } /** * Calcule le statut de confiance du client. * * Confiant : 0 impaye * Attention : 1 impaye (avis ou echeance) * Danger : echeancier annule avec rejets, ou 3+ avis impayes, ou 2+ impayes * * @param list $adverts * @param list $echeanciers * * @return array{status: string, label: string, color: string, reason: string} */ private function computeTrustStatus(array $adverts, array $echeanciers, Customer $customer): array { // Avertissements : 2nd = Attention, last = Danger $warningLevel = $customer->getWarningLevel(); if ('last' === $warningLevel) { return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => 'Dernier avertissement envoye']; } if ('2nd' === $warningLevel) { return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => '2eme avertissement envoye']; } // Compter les avis impayes (envoyes mais pas acceptes/refuses/annules) $nbUnpaidAdverts = 0; foreach ($adverts as $advert) { if (\in_array($advert->getState(), [Advert::STATE_CREATED, Advert::STATE_SEND], true)) { ++$nbUnpaidAdverts; } } // Verifier les echeanciers annules avec rejets $hasCancelledWithRejects = false; $nbUnpaidEcheances = 0; foreach ($echeanciers as $echeancier) { if (Echeancier::STATE_CANCELLED === $echeancier->getState() && $echeancier->getNbFailed() > 0) { $hasCancelledWithRejects = true; } if (\in_array($echeancier->getState(), [Echeancier::STATE_ACTIVE, Echeancier::STATE_PENDING_SETUP, Echeancier::STATE_SIGNED], true)) { foreach ($echeancier->getLines() as $line) { if ('ok' !== $line->getState()) { ++$nbUnpaidEcheances; } } } } $totalUnpaid = $nbUnpaidAdverts + ($nbUnpaidEcheances > 0 ? 1 : 0); // Danger if ($hasCancelledWithRejects) { return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => 'Echeancier annule suite a des rejets de prelevement']; } if ($nbUnpaidAdverts >= 3) { return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => $nbUnpaidAdverts.' avis de paiement impayes']; } if ($totalUnpaid >= 2) { return ['status' => 'danger', 'label' => 'Danger', 'color' => 'red', 'reason' => $totalUnpaid.' impayes (avis + echeanciers)']; } // Attention (1er avertissement ou 1 impaye) if ('1st' === $warningLevel) { return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => '1er avertissement envoye']; } if ($totalUnpaid >= 1) { $reason = $nbUnpaidAdverts > 0 ? $nbUnpaidAdverts.' avis impaye(s)' : 'Echeancier en cours'; return ['status' => 'attention', 'label' => 'Attention', 'color' => 'yellow', 'reason' => $reason]; } return ['status' => 'confiant', 'label' => 'Confiant', 'color' => 'green', 'reason' => 'Aucun impaye']; } private function handleContactForm(Request $request, Customer $customer, EntityManagerInterface $em): Response { $action = $request->request->getString('contact_action'); if ('create' === $action) { $this->persistNewContact($request, $customer, $em); } elseif ('delete' === $action) { $contactId = $request->request->getInt('contact_id'); $contact = $em->getRepository(\App\Entity\CustomerContact::class)->find($contactId); if (null !== $contact && $contact->getCustomer() === $customer) { $em->remove($contact); $em->flush(); $this->addFlash('success', 'Contact supprime.'); } } return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'contacts']); } private function persistNewContact(Request $request, Customer $customer, EntityManagerInterface $em): void { $firstName = trim($request->request->getString('contact_firstName')); $lastName = trim($request->request->getString('contact_lastName')); if ('' === $firstName || '' === $lastName) { return; } $contact = new \App\Entity\CustomerContact($customer, $firstName, $lastName); $contact->setEmail(trim($request->request->getString('contact_email')) ?: null); $contact->setPhone(trim($request->request->getString('contact_phone')) ?: null); $contact->setRole(trim($request->request->getString('contact_role')) ?: null); $contact->setIsBillingEmail($request->request->getBoolean('contact_isBilling')); $em->persist($contact); $em->flush(); $this->addFlash('success', 'Contact '.$firstName.' '.$lastName.' ajoute.'); } private function handleDomainForm(Request $request, Customer $customer, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): Response { $action = $request->request->getString('domain_action'); if ('create' === $action) { $fqdn = strtolower(trim($request->request->getString('domain_fqdn'))); $registrar = $request->request->getString('domain_registrar') ?: null; if ('' === $fqdn) { $this->addFlash('error', 'Le nom de domaine est requis.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']); } $existing = $em->getRepository(Domain::class)->findOneBy(['fqdn' => $fqdn]); if (null !== $existing) { $this->addFlash('error', 'Le domaine '.$fqdn.' existe deja.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']); } $domain = new Domain($customer, $fqdn); $domain->setRegistrar($registrar); $this->autoDetectDomain($domain, $ovhService, $cloudflareService, $dnsCheckService); $em->persist($domain); $em->flush(); $this->addFlash('success', 'Domaine '.$fqdn.' ajoute.'); } if ('delete' === $action) { $domainId = $request->request->getInt('domain_id'); $domain = $em->getRepository(Domain::class)->find($domainId); if (null !== $domain && $domain->getCustomer() === $customer) { $em->remove($domain); $em->flush(); $this->addFlash('success', 'Domaine supprime.'); } } return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']); } private function autoDetectDomain(Domain $domain, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): void { $fqdn = $domain->getFqdn(); // Check OVH if ($ovhService->isDomainManaged($fqdn)) { $domain->setRegistrar('OVH'); $domain->setIsGestion(true); $domain->setIsBilling(true); $serviceInfo = $ovhService->getDomainServiceInfo($fqdn); if (null !== $serviceInfo) { $domain->setExpiredAt(new \DateTimeImmutable($serviceInfo['expiration'])); $domain->setUpdatedAt(new \DateTimeImmutable()); } $zoneInfo = $ovhService->getZoneInfo($fqdn); if (null !== $zoneInfo) { $domain->setZoneCloudflare(null); $domain->setZoneIdCloudflare(null); } } // Check Cloudflare if ($cloudflareService->isAvailable()) { $zoneId = $cloudflareService->getZoneId($fqdn); if (null !== $zoneId) { $domain->setZoneCloudflare('active'); $domain->setZoneIdCloudflare($zoneId); if (null === $domain->getRegistrar()) { $domain->setRegistrar('Cloudflare'); $domain->setIsGestion(true); $domain->setIsBilling(true); } } } // Fallback RDAP pour l'expiration si pas encore trouvée if (null === $domain->getExpiredAt()) { $expiration = $dnsCheckService->getExpirationDate($fqdn); if (null !== $expiration) { $domain->setExpiredAt($expiration); } } } private function handleSecurityForm(Request $request, Customer $customer, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, MailerService $mailer, Environment $twig): Response { $action = $request->request->getString('security_action'); $user = $customer->getUser(); if ('send_reset_password' === $action) { $tempPassword = bin2hex(random_bytes(8)); $user->setPassword($passwordHasher->hashPassword($user, $tempPassword)); $user->setTempPassword($tempPassword); $em->flush(); $setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL); $mailer->sendEmail( $user->getEmail(), self::WELCOME_SUBJECT, $twig->render(self::WELCOME_TEMPLATE, [ 'firstName' => $user->getFirstName(), 'email' => $user->getEmail(), 'setPasswordUrl' => $setPasswordUrl, ]), ); $this->addFlash('success', 'Un email de reinitialisation a ete envoye a '.$user->getEmail().'.'); } if ('disable_2fa' === $action) { $user->setIsEmailAuthEnabled(false); $user->setIsGoogleAuthEnabled(false); $user->setGoogleAuthenticatorSecret(null); $user->setBackupCodes([]); $em->flush(); $this->addFlash('success', 'Authentification a deux facteurs desactivee.'); } return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'securite']); } #[Route('/{id}/resend-welcome', name: 'resend_welcome', methods: ['POST'])] public function resendWelcome(Customer $customer, MailerService $mailer, Environment $twig): Response { $user = $customer->getUser(); if (!$user->hasTempPassword()) { $this->addFlash('error', 'Le mot de passe temporaire n\'est plus disponible. Le client a deja active son compte.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]); } $setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL); $mailer->sendEmail( $user->getEmail(), self::WELCOME_SUBJECT, $twig->render(self::WELCOME_TEMPLATE, [ 'firstName' => $user->getFirstName(), 'email' => $user->getEmail(), 'setPasswordUrl' => $setPasswordUrl, ]), ); $this->addFlash('success', 'Email de bienvenue renvoye a '.$user->getEmail().'.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]); } #[Route('/{id}/toggle', name: 'toggle', methods: ['POST'])] public function toggle(Customer $customer, EntityManagerInterface $em, MeilisearchService $meilisearch, LoggerInterface $logger): Response { $newState = $customer->isActive() ? Customer::STATE_SUSPENDED : Customer::STATE_ACTIVE; $customer->setState($newState); $em->flush(); try { $meilisearch->indexCustomer($customer); } catch (\Throwable $e) { $logger->error('Meilisearch: impossible d\'indexer le client '.$customer->getId().': '.$e->getMessage()); } $this->addFlash('success', 'Client '.($customer->isActive() ? 'active' : 'suspendu').'.'); return $this->redirectToRoute('app_admin_clients_index'); } #[Route('/{id}/delete', name: 'delete', methods: ['POST'])] public function delete(Customer $customer, EntityManagerInterface $em): Response { if ($customer->isPendingDelete()) { $this->addFlash('error', 'Ce client est deja en attente de suppression.'); return $this->redirectToRoute('app_admin_clients_index'); } $customer->setState(Customer::STATE_PENDING_DELETE); $em->flush(); $this->addFlash('success', 'Client "'.$customer->getFullName().'" marque pour suppression. Il sera supprime automatiquement cette nuit.'); return $this->redirectToRoute('app_admin_clients_index'); } /** * Envoie un avertissement au client (1st, 2nd, last). */ #[Route('/{id}/send-warning/{level}', name: 'send_warning', requirements: ['id' => '\d+', 'level' => '1st|2nd|last'], methods: ['POST'])] #[IsGranted('ROLE_ROOT')] public function sendWarning( int $id, string $level, Request $request, EntityManagerInterface $em, \App\Service\DocuSealService $docuSeal, \Symfony\Component\HttpKernel\KernelInterface $kernel, ): Response { $customer = $em->getRepository(Customer::class)->find($id); if (null === $customer) { throw $this->createNotFoundException('Client introuvable'); } if (null === $customer->getEmail()) { $this->addFlash('error', 'Email client introuvable.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']); } $reasons = $request->request->all('reasons'); // Generer le PDF $pdf = new \App\Service\Pdf\ClientWarningPdf($kernel, $customer, $level, $reasons); $pdf->generate(); $tmpPath = tempnam(sys_get_temp_dir(), 'warning_').'.pdf'; $pdf->Output('F', $tmpPath); $warningLabels = [ '1st' => '1er avertissement', '2nd' => '2eme avertissement', 'last' => 'Dernier avertissement avant suspension', ]; // Envoyer a DocuSeal pour auto-signature // Le webhook (doc_type=client_warning) enverra le mail avec le PDF signe // @codeCoverageIgnoreStart try { $pdfBase64 = base64_encode(file_get_contents($tmpPath)); $docuSeal->getApi()->createSubmissionFromPdf([ 'name' => 'Avertissement '.$warningLabels[$level].' - '.$customer->getFullName(), 'send_email' => false, 'flatten' => true, 'documents' => [[ 'name' => 'avertissement-'.$level.'-'.$customer->getId().'.pdf', 'file' => 'data:application/pdf;base64,'.$pdfBase64, ]], 'submitters' => [[ 'email' => 'contact@e-cosplay.fr', 'name' => 'Association E-Cosplay', 'role' => 'First Party', 'completed' => true, 'send_email' => false, 'values' => ['Sign' => $docuSeal->getLogoBase64()], 'metadata' => [ 'doc_type' => 'client_warning', 'customer_id' => $customer->getId(), 'level' => $level, 'reasons' => implode(',', $reasons), ], ]], ]); $this->addFlash('success', $warningLabels[$level].' envoye pour signature. Le client recevra le PDF signe automatiquement.'); } catch (\Throwable $e) { $this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage()); } // @codeCoverageIgnoreEnd @unlink($tmpPath); $customer->setWarningLevel($level); $customer->setWarningAt(new \DateTimeImmutable()); $em->flush(); return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']); } /** * Reinitialise les avertissements du client. */ /** * Envoie la notification de cloture (PDF signe via DocuSeal) - n'effectue PAS la suppression. */ #[Route('/{id}/close-account', name: 'close_account', requirements: ['id' => '\d+'], methods: ['POST'])] #[IsGranted('ROLE_ROOT')] public function closeAccount( int $id, EntityManagerInterface $em, \App\Service\DocuSealService $docuSeal, \Symfony\Component\HttpKernel\KernelInterface $kernel, ): Response { $customer = $em->getRepository(Customer::class)->find($id); if (null === $customer) { throw $this->createNotFoundException('Client introuvable'); } if (null === $customer->getEmail()) { $this->addFlash('error', 'Email client introuvable.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']); } // Generer le PDF de cloture $pdf = new \App\Service\Pdf\ClientClosurePdf($kernel, $customer); $pdf->generate(); $tmpPath = tempnam(sys_get_temp_dir(), 'closure_').'.pdf'; $pdf->Output('F', $tmpPath); // Envoyer a DocuSeal pour auto-signature // Le webhook (doc_type=client_closure) enverra le mail avec le PDF signe // @codeCoverageIgnoreStart try { $pdfBase64 = base64_encode(file_get_contents($tmpPath)); $docuSeal->getApi()->createSubmissionFromPdf([ 'name' => 'Cloture compte - '.$customer->getFullName(), 'send_email' => false, 'flatten' => true, 'documents' => [[ 'name' => 'cloture-'.$customer->getId().'.pdf', 'file' => 'data:application/pdf;base64,'.$pdfBase64, ]], 'submitters' => [[ 'email' => 'contact@e-cosplay.fr', 'name' => 'Association E-Cosplay', 'role' => 'First Party', 'completed' => true, 'send_email' => false, 'values' => ['Sign' => $docuSeal->getLogoBase64()], 'metadata' => [ 'doc_type' => 'client_closure', 'customer_id' => $customer->getId(), ], ]], ]); $this->addFlash('success', 'Notification de cloture envoyee pour signature. Le client recevra le PDF signe.'); } catch (\Throwable $e) { $this->addFlash('error', 'Erreur DocuSeal : '.$e->getMessage()); } // @codeCoverageIgnoreEnd @unlink($tmpPath); return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']); } /** * Effectue la suspension reelle du compte (state = suspended). */ #[Route('/{id}/suspend-account', name: 'suspend_account', requirements: ['id' => '\d+'], methods: ['POST'])] #[IsGranted('ROLE_ROOT')] public function suspendAccount(int $id, EntityManagerInterface $em): Response { $customer = $em->getRepository(Customer::class)->find($id); if (null === $customer) { throw $this->createNotFoundException('Client introuvable'); } $customer->setState('suspended'); $customer->setUpdatedAt(new \DateTimeImmutable()); $em->flush(); $this->addFlash('success', 'Compte de '.$customer->getFullName().' suspendu.'); return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']); } #[Route('/{id}/reset-warning', name: 'reset_warning', requirements: ['id' => '\d+'], methods: ['POST'])] #[IsGranted('ROLE_ROOT')] public function resetWarning( int $id, EntityManagerInterface $em, \App\Service\DocuSealService $docuSeal, \Symfony\Component\HttpKernel\KernelInterface $kernel, ): Response { $customer = $em->getRepository(Customer::class)->find($id); if (null === $customer) { throw $this->createNotFoundException('Client introuvable'); } $customer->setWarningLevel(null); $customer->setWarningAt(null); $em->flush(); // Generer le PDF de levee d'avertissement et envoyer a DocuSeal // Le webhook (doc_type=client_warning_reset) enverra le mail avec le PDF signe // @codeCoverageIgnoreStart if (null !== $customer->getEmail()) { try { $pdf = new \App\Service\Pdf\ClientWarningResetPdf($kernel, $customer); $pdf->generate(); $tmpPath = tempnam(sys_get_temp_dir(), 'reset_').'.pdf'; $pdf->Output('F', $tmpPath); $pdfBase64 = base64_encode(file_get_contents($tmpPath)); $docuSeal->getApi()->createSubmissionFromPdf([ 'name' => 'Levee avertissement - '.$customer->getFullName(), 'send_email' => false, 'flatten' => true, 'documents' => [[ 'name' => 'levee-avertissement-'.$customer->getId().'.pdf', 'file' => 'data:application/pdf;base64,'.$pdfBase64, ]], 'submitters' => [[ 'email' => 'contact@e-cosplay.fr', 'name' => 'Association E-Cosplay', 'role' => 'First Party', 'completed' => true, 'send_email' => false, 'values' => ['Sign' => $docuSeal->getLogoBase64()], 'metadata' => [ 'doc_type' => 'client_warning_reset', 'customer_id' => $customer->getId(), ], ]], ]); @unlink($tmpPath); $this->addFlash('success', 'Avertissements reinitialises. Le client recevra le PDF signe de levee d\'avertissement.'); } catch (\Throwable $e) { $this->addFlash('warning', 'Avertissements reinitialises mais erreur DocuSeal : '.$e->getMessage()); } } else { $this->addFlash('success', 'Avertissements reinitialises.'); } // @codeCoverageIgnoreEnd return $this->redirectToRoute('app_admin_clients_show', ['id' => $id, 'tab' => 'controle']); } }