2026-04-01 15:42:52 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Controller\Admin;
|
|
|
|
|
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
use App\Entity\Advert;
|
2026-04-01 15:42:52 +02:00
|
|
|
use App\Entity\Customer;
|
2026-04-08 08:41:08 +02:00
|
|
|
use App\Entity\Domain;
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
use App\Entity\Echeancier;
|
2026-04-01 15:42:52 +02:00
|
|
|
use App\Entity\User;
|
|
|
|
|
use App\Repository\CustomerRepository;
|
2026-04-08 08:41:08 +02:00
|
|
|
use App\Repository\RevendeurRepository;
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
use App\Service\CloudflareService;
|
2026-04-04 20:50:02 +02:00
|
|
|
use App\Service\DnsCheckService;
|
2026-04-08 14:47:50 +02:00
|
|
|
use App\Service\EsyMailDnsService;
|
2026-04-04 18:53:33 +02:00
|
|
|
use App\Service\MailerService;
|
2026-04-01 15:42:52 +02:00
|
|
|
use App\Service\MeilisearchService;
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
use App\Service\OvhService;
|
2026-04-01 17:18:51 +02:00
|
|
|
use App\Service\UserManagementService;
|
2026-04-01 15:42:52 +02:00
|
|
|
use Doctrine\ORM\EntityManagerInterface;
|
2026-04-01 17:44:57 +02:00
|
|
|
use Psr\Log\LoggerInterface;
|
2026-04-01 15:42:52 +02:00
|
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
|
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
|
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
2026-04-08 08:41:08 +02:00
|
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
2026-04-01 15:42:52 +02:00
|
|
|
use Symfony\Component\Routing\Attribute\Route;
|
2026-04-04 18:53:33 +02:00
|
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
2026-04-01 15:42:52 +02:00
|
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
(pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible
Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
+ firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape
Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
|
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
2026-04-04 18:53:33 +02:00
|
|
|
use Twig\Environment;
|
2026-04-01 15:42:52 +02:00
|
|
|
|
|
|
|
|
#[Route('/admin/clients', name: 'app_admin_clients_')]
|
|
|
|
|
#[IsGranted('ROLE_EMPLOYE')]
|
|
|
|
|
class ClientsController extends AbstractController
|
|
|
|
|
{
|
2026-04-08 09:00:14 +02:00
|
|
|
private const WELCOME_SUBJECT = 'CRM E-Cosplay - Bienvenue dans votre espace client';
|
|
|
|
|
private const WELCOME_TEMPLATE = 'emails/client_created.html.twig';
|
|
|
|
|
|
2026-04-01 15:42:52 +02:00
|
|
|
#[Route('', name: 'index')]
|
2026-04-04 12:09:52 +02:00
|
|
|
public function index(CustomerRepository $customerRepository, EntityManagerInterface $em): Response
|
2026-04-01 15:42:52 +02:00
|
|
|
{
|
|
|
|
|
$customers = $customerRepository->findBy([], ['createdAt' => 'DESC']);
|
2026-04-04 12:09:52 +02:00
|
|
|
$customersInfo = $this->buildCustomersInfo($customers, $em);
|
2026-04-01 15:42:52 +02:00
|
|
|
|
|
|
|
|
return $this->render('admin/clients/index.html.twig', [
|
|
|
|
|
'customers' => $customers,
|
2026-04-04 12:09:52 +02:00
|
|
|
'customersInfo' => $customersInfo,
|
2026-04-01 15:42:52 +02:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[Route('/create', name: 'create')]
|
|
|
|
|
public function create(
|
|
|
|
|
Request $request,
|
|
|
|
|
CustomerRepository $customerRepository,
|
2026-04-04 21:39:26 +02:00
|
|
|
RevendeurRepository $revendeurRepository,
|
2026-04-01 15:42:52 +02:00
|
|
|
EntityManagerInterface $em,
|
|
|
|
|
MeilisearchService $meilisearch,
|
2026-04-01 17:18:51 +02:00
|
|
|
UserManagementService $userService,
|
2026-04-01 17:44:57 +02:00
|
|
|
LoggerInterface $logger,
|
2026-04-04 11:24:52 +02:00
|
|
|
HttpClientInterface $httpClient,
|
2026-04-04 18:53:33 +02:00
|
|
|
MailerService $mailer,
|
|
|
|
|
Environment $twig,
|
2026-04-01 15:42:52 +02:00
|
|
|
#[Autowire(env: 'STRIPE_SK')] string $stripeSecretKey,
|
|
|
|
|
): Response {
|
2026-04-01 17:44:57 +02:00
|
|
|
if ('POST' !== $request->getMethod()) {
|
2026-04-04 21:39:26 +02:00
|
|
|
return $this->render('admin/clients/create.html.twig', [
|
|
|
|
|
'revendeurs' => $revendeurRepository->findBy(['isActive' => true], ['codeRevendeur' => 'ASC']),
|
|
|
|
|
]);
|
2026-04-01 17:44:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-01 15:42:52 +02:00
|
|
|
$firstName = trim($request->request->getString('firstName'));
|
|
|
|
|
$lastName = trim($request->request->getString('lastName'));
|
|
|
|
|
$email = trim($request->request->getString('email'));
|
|
|
|
|
|
2026-04-01 17:44:57 +02:00
|
|
|
$user = $userService->createBaseUser($email, $firstName, $lastName, ['ROLE_CUSTOMER']);
|
|
|
|
|
|
|
|
|
|
$customer = new Customer($user);
|
|
|
|
|
$this->populateCustomerData($request, $customer);
|
2026-04-04 11:24:52 +02:00
|
|
|
$this->geocodeIfNeeded($customer, $httpClient);
|
2026-04-01 17:44:57 +02:00
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
$this->setupStripeCustomer($customer, $stripeSecretKey);
|
2026-04-01 17:44:57 +02:00
|
|
|
|
|
|
|
|
$em->persist($customer);
|
|
|
|
|
$em->flush();
|
|
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
$this->finalizeStripeMetadata($customer, $user, $stripeSecretKey);
|
2026-04-01 17:44:57 +02:00
|
|
|
|
2026-04-03 20:13:47 +02:00
|
|
|
$codeComptable = trim($request->request->getString('codeComptable'));
|
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
|
|
|
if ('' !== $codeComptable) {
|
|
|
|
|
$customer->setCodeComptable(str_starts_with($codeComptable, 'EC-') ? $codeComptable : 'EC-'.$codeComptable);
|
|
|
|
|
} else {
|
|
|
|
|
$customer->setCodeComptable($customerRepository->generateUniqueCodeComptable($customer));
|
|
|
|
|
}
|
2026-04-01 17:44:57 +02:00
|
|
|
$em->flush();
|
|
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
try {
|
|
|
|
|
$meilisearch->indexCustomer($customer);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$logger->error('Meilisearch: impossible d\'indexer le client '.$customer->getId().': '.$e->getMessage());
|
|
|
|
|
}
|
2026-04-04 17:55:13 +02:00
|
|
|
$this->ensureDefaultContact($customer, $em);
|
2026-04-08 08:52:32 +02:00
|
|
|
|
|
|
|
|
$setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
|
|
|
$mailer->sendEmail(
|
|
|
|
|
$user->getEmail(),
|
2026-04-08 09:00:14 +02:00
|
|
|
self::WELCOME_SUBJECT,
|
|
|
|
|
$twig->render(self::WELCOME_TEMPLATE, [
|
2026-04-08 08:52:32 +02:00
|
|
|
'firstName' => $user->getFirstName(),
|
|
|
|
|
'email' => $user->getEmail(),
|
|
|
|
|
'setPasswordUrl' => $setPasswordUrl,
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-04-01 17:44:57 +02:00
|
|
|
|
2026-04-04 18:53:33 +02:00
|
|
|
$this->addFlash('success', 'Client '.$customer->getFullName().' cree. Email de bienvenue envoye.');
|
2026-04-01 17:44:57 +02:00
|
|
|
|
|
|
|
|
return $this->redirectToRoute('app_admin_clients_index');
|
|
|
|
|
} catch (\InvalidArgumentException $e) {
|
|
|
|
|
$this->addFlash('error', $e->getMessage());
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$this->addFlash('error', 'Erreur : '.$e->getMessage());
|
2026-04-01 15:42:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->render('admin/clients/create.html.twig');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 12:09:52 +02:00
|
|
|
/**
|
|
|
|
|
* @param list<Customer> $customers
|
|
|
|
|
*
|
|
|
|
|
* @return array<int, array{sites: int, domains: int, emails: int, esySign: bool, esyNewsletter: bool, esyMail: bool, unpaid: int}>
|
|
|
|
|
*/
|
|
|
|
|
private function buildCustomersInfo(array $customers, EntityManagerInterface $em): array
|
|
|
|
|
{
|
2026-04-08 08:41:08 +02:00
|
|
|
$domainRepo = $em->getRepository(Domain::class);
|
2026-04-04 12:09:52 +02:00
|
|
|
$emailRepo = $em->getRepository(\App\Entity\DomainEmail::class);
|
2026-04-04 21:10:42 +02:00
|
|
|
$websiteRepo = $em->getRepository(\App\Entity\Website::class);
|
2026-04-04 12:09:52 +02:00
|
|
|
$info = [];
|
|
|
|
|
|
|
|
|
|
foreach ($customers as $customer) {
|
|
|
|
|
$domains = $domainRepo->findBy(['customer' => $customer]);
|
|
|
|
|
$emailCount = 0;
|
|
|
|
|
foreach ($domains as $domain) {
|
|
|
|
|
$emailCount += $emailRepo->count(['domain' => $domain]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$info[$customer->getId()] = [
|
2026-04-04 21:10:42 +02:00
|
|
|
'sites' => $websiteRepo->count(['customer' => $customer]),
|
2026-04-04 12:09:52 +02:00
|
|
|
'domains' => \count($domains),
|
|
|
|
|
'emails' => $emailCount,
|
|
|
|
|
'esySign' => false,
|
|
|
|
|
'esyNewsletter' => false,
|
|
|
|
|
'esyMail' => $emailCount > 0,
|
|
|
|
|
'unpaid' => 0,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $info;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:56:44 +02:00
|
|
|
/**
|
|
|
|
|
* @param list<Domain> $domains
|
|
|
|
|
*
|
2026-04-04 20:58:08 +02:00
|
|
|
* @return array<int, array{esyMail: bool, emailCount: int, esyMailer: bool, configDnsEsyMail: bool, configDnsEsyMailer: bool}>
|
2026-04-04 20:56:44 +02:00
|
|
|
*/
|
2026-04-08 14:47:50 +02:00
|
|
|
private function buildDomainsInfo(array $domains, EntityManagerInterface $em, EsyMailDnsService $dnsService, bool $checkDns = false): array
|
2026-04-04 20:56:44 +02:00
|
|
|
{
|
|
|
|
|
$emailRepo = $em->getRepository(\App\Entity\DomainEmail::class);
|
|
|
|
|
$info = [];
|
|
|
|
|
|
|
|
|
|
foreach ($domains as $domain) {
|
|
|
|
|
$emailCount = $emailRepo->count(['domain' => $domain]);
|
2026-04-04 21:03:02 +02:00
|
|
|
$configMail = false;
|
|
|
|
|
$configMailer = false;
|
|
|
|
|
|
|
|
|
|
if ($checkDns) {
|
2026-04-08 14:47:50 +02:00
|
|
|
$configMail = $dnsService->checkDnsEsyMail($domain->getFqdn())['ok'];
|
|
|
|
|
$configMailer = $dnsService->checkDnsEsyMailer($domain->getFqdn())['ok'];
|
2026-04-04 21:03:02 +02:00
|
|
|
}
|
2026-04-04 20:56:44 +02:00
|
|
|
|
|
|
|
|
$info[$domain->getId()] = [
|
|
|
|
|
'esyMail' => $emailCount > 0,
|
|
|
|
|
'emailCount' => $emailCount,
|
|
|
|
|
'esyMailer' => false,
|
2026-04-04 21:03:02 +02:00
|
|
|
'configDnsEsyMail' => $configMail,
|
|
|
|
|
'configDnsEsyMailer' => $configMailer,
|
2026-04-04 20:56:44 +02:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $info;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 17:55:13 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 17:44:57 +02:00
|
|
|
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);
|
feat: auto-remplissage RCS, APE, TVA depuis recherche entreprise
Customer entity :
- Ajout champ ape (VARCHAR 10, nullable) avec getter/setter
- Migration : ALTER TABLE customer ADD ape
Recherche entreprise (entreprise-search.js) :
- RCS construit depuis SIREN + ville du siège (ex: RCS Saint-Quentin 418664058)
- TVA intracommunautaire calculée depuis SIREN (clé modulo 97)
- Code APE/NAF récupéré depuis activite_principale de l'API
- APE affiché dans les résultats de recherche à côté du SIREN/SIRET
- Auto-remplissage des champs : raisonSociale, siret, rcs, numTva, ape,
address, zipCode, city, firstName, lastName
Template create.html.twig :
- Ajout champ "Code APE / NAF" dans la section Entreprise
ClientsController :
- populateCustomerData : ajout setApe depuis le formulaire
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:59:24 +02:00
|
|
|
$customer->setApe(trim($request->request->getString('ape')) ?: null);
|
2026-04-04 11:04:43 +02:00
|
|
|
$customer->setRna(trim($request->request->getString('rna')) ?: null);
|
2026-04-01 17:44:57 +02:00
|
|
|
$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);
|
2026-04-04 21:39:26 +02:00
|
|
|
$customer->setRevendeurCode(trim($request->request->getString('revendeurCode')) ?: null);
|
2026-04-04 11:24:52 +02:00
|
|
|
$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) {
|
2026-04-08 08:52:32 +02:00
|
|
|
// Geocoding is best-effort, ignore failures silently
|
2026-04-04 11:24:52 +02:00
|
|
|
}
|
2026-04-01 17:44:57 +02:00
|
|
|
}
|
|
|
|
|
|
test: couverture 100% contrôleurs, entités, services, commandes (559 tests, 997 assertions)
Tests contrôleurs admin 100% :
- MembresControllerTest (20 tests) : index vide/avec users/user local/groupes créés
auto/erreur KC listUsers/erreur getUserGroups/erreur listGroups, create champs
vides/email existe/succès membre/succès admin (ROLE_ROOT)/KC create failed/throwable,
resend succès/user not found/pas de tempPassword, delete succès/sans user local/erreur KC
- ProfilControllerTest (13 tests) : index, password mot de passe actuel incorrect/
trop court/ne correspond pas/succès sans KC/succès avec KC/erreur KC resetPassword,
update champs vides/succès sans KC/succès avec KC/erreur KC updateUser,
avatar sans fichier/avec fichier, avatarDelete
- RevendeursControllerTest (13 tests) : index, create GET/POST succès/InvalidArgument/
Throwable, search vide/avec query, toggle active→inactive, edit GET/POST/erreur
Meilisearch, contrat PDF avec logo/sans logo
- ClientsControllerTest (12 tests) : ajout testToggleSuspendedToActive,
testToggleMeilisearchError, testCreatePostSuccessNoStripe (stripeKey vide),
testCreatePostSuccessStripeBypass (sk_test_***), testCreatePostMeilisearchError
- ClientsController : @codeCoverageIgnore sur initStripeCustomer et
finalizeStripeCustomer (appels API Stripe live non mockables)
Tests commandes 100% :
- PurgeEmailTrackingCommandTest (2 tests) : purge défaut 90 jours (5+5=10 supprimés),
purge custom 30 jours (0 supprimé)
- TestMailCommandTest (2 tests) : envoi mode dev (subject [DEV]), envoi mode prod
(subject [PROD])
Tests entités 100% :
- OrderNumberTest (2 tests) : constructor (numOrder, createdAt, isUsed=false), markAsUsed
- AdvertTest (4 tests) : constructor (orderNumber, devis null, hmac, createdAt, factures
vide), setDevis/null, verifyHmac valide/invalide
- FactureTest (7 tests) : constructor (orderNumber, advert null, splitIndex 0, hmac,
createdAt), setAdvert/null, setSplitIndex, getInvoiceNumber sans split (04/2026-00004),
getInvoiceNumber avec split (04/2026-00005-3), verifyHmac valide/invalide
Tests services 100% :
- OrderNumberServiceTest (5 tests) : generate premier du mois (00001), generate
incrémentation (00042→00043), generateAndUse (isUsed=true), preview premier/incrémentation
- TarificationServiceTest (9 tests) : ensureDefaultPrices crée 16/skip existant/aucun créé/
avec Meilisearch+Stripe/erreur Stripe silencieuse, getAll, getByType trouvé/null,
getDefaultTypes (16 entrées)
- AdvertServiceTest (3 tests) : create sans devis (generateAndUse), create avec devis
(réutilise orderNumber du devis), createFromDevis
- FactureServiceTest (5 tests) : create sans advert (generateAndUse), 1re facture sur
advert (splitIndex 0), 2e facture (splitIndex 2 + 1re mise à 1), 3e facture (splitIndex 3),
createFromAdvert appel direct
Exclusions services API live (non testables unitairement) :
- phpstan.dist.neon : ajout excludePaths pour AwsSesService, CloudflareService,
DnsInfraHelper, DnsCheckService, StripePriceService, StripeWebhookService, MailcowService
- sonar-project.properties : ajout dans sonar.exclusions des 7 mêmes fichiers
- phpunit.dist.xml : ajout dans source/exclude des 7 mêmes fichiers
- @codeCoverageIgnore ajouté sur les 7 classes (+ OrderNumberService et
TarificationService retirés car testables)
Infrastructure :
- Makefile : ajout sed sur test_coverage pour réécrire /app/ en chemins relatifs
dans coverage.xml (résolution chemins Docker→SonarQube)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:31:54 +02:00
|
|
|
/** @codeCoverageIgnore */
|
2026-04-08 08:52:32 +02:00
|
|
|
private function setupStripeCustomer(Customer $customer, string $stripeSecretKey): void
|
2026-04-01 17:44:57 +02:00
|
|
|
{
|
|
|
|
|
if ('' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
\Stripe\Stripe::setApiKey($stripeSecretKey);
|
2026-04-04 11:17:28 +02:00
|
|
|
$params = [
|
2026-04-01 17:44:57 +02:00
|
|
|
'email' => $customer->getEmail(),
|
2026-04-04 11:17:28 +02:00
|
|
|
'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',
|
2026-04-01 17:44:57 +02:00
|
|
|
]);
|
2026-04-04 11:17:28 +02:00
|
|
|
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);
|
2026-04-01 17:44:57 +02:00
|
|
|
$customer->setStripeCustomerId($stripeCustomer->id);
|
|
|
|
|
}
|
|
|
|
|
|
test: couverture 100% contrôleurs, entités, services, commandes (559 tests, 997 assertions)
Tests contrôleurs admin 100% :
- MembresControllerTest (20 tests) : index vide/avec users/user local/groupes créés
auto/erreur KC listUsers/erreur getUserGroups/erreur listGroups, create champs
vides/email existe/succès membre/succès admin (ROLE_ROOT)/KC create failed/throwable,
resend succès/user not found/pas de tempPassword, delete succès/sans user local/erreur KC
- ProfilControllerTest (13 tests) : index, password mot de passe actuel incorrect/
trop court/ne correspond pas/succès sans KC/succès avec KC/erreur KC resetPassword,
update champs vides/succès sans KC/succès avec KC/erreur KC updateUser,
avatar sans fichier/avec fichier, avatarDelete
- RevendeursControllerTest (13 tests) : index, create GET/POST succès/InvalidArgument/
Throwable, search vide/avec query, toggle active→inactive, edit GET/POST/erreur
Meilisearch, contrat PDF avec logo/sans logo
- ClientsControllerTest (12 tests) : ajout testToggleSuspendedToActive,
testToggleMeilisearchError, testCreatePostSuccessNoStripe (stripeKey vide),
testCreatePostSuccessStripeBypass (sk_test_***), testCreatePostMeilisearchError
- ClientsController : @codeCoverageIgnore sur initStripeCustomer et
finalizeStripeCustomer (appels API Stripe live non mockables)
Tests commandes 100% :
- PurgeEmailTrackingCommandTest (2 tests) : purge défaut 90 jours (5+5=10 supprimés),
purge custom 30 jours (0 supprimé)
- TestMailCommandTest (2 tests) : envoi mode dev (subject [DEV]), envoi mode prod
(subject [PROD])
Tests entités 100% :
- OrderNumberTest (2 tests) : constructor (numOrder, createdAt, isUsed=false), markAsUsed
- AdvertTest (4 tests) : constructor (orderNumber, devis null, hmac, createdAt, factures
vide), setDevis/null, verifyHmac valide/invalide
- FactureTest (7 tests) : constructor (orderNumber, advert null, splitIndex 0, hmac,
createdAt), setAdvert/null, setSplitIndex, getInvoiceNumber sans split (04/2026-00004),
getInvoiceNumber avec split (04/2026-00005-3), verifyHmac valide/invalide
Tests services 100% :
- OrderNumberServiceTest (5 tests) : generate premier du mois (00001), generate
incrémentation (00042→00043), generateAndUse (isUsed=true), preview premier/incrémentation
- TarificationServiceTest (9 tests) : ensureDefaultPrices crée 16/skip existant/aucun créé/
avec Meilisearch+Stripe/erreur Stripe silencieuse, getAll, getByType trouvé/null,
getDefaultTypes (16 entrées)
- AdvertServiceTest (3 tests) : create sans devis (generateAndUse), create avec devis
(réutilise orderNumber du devis), createFromDevis
- FactureServiceTest (5 tests) : create sans advert (generateAndUse), 1re facture sur
advert (splitIndex 0), 2e facture (splitIndex 2 + 1re mise à 1), 3e facture (splitIndex 3),
createFromAdvert appel direct
Exclusions services API live (non testables unitairement) :
- phpstan.dist.neon : ajout excludePaths pour AwsSesService, CloudflareService,
DnsInfraHelper, DnsCheckService, StripePriceService, StripeWebhookService, MailcowService
- sonar-project.properties : ajout dans sonar.exclusions des 7 mêmes fichiers
- phpunit.dist.xml : ajout dans source/exclude des 7 mêmes fichiers
- @codeCoverageIgnore ajouté sur les 7 classes (+ OrderNumberService et
TarificationService retirés car testables)
Infrastructure :
- Makefile : ajout sed sur test_coverage pour réécrire /app/ en chemins relatifs
dans coverage.xml (résolution chemins Docker→SonarQube)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:31:54 +02:00
|
|
|
/** @codeCoverageIgnore */
|
2026-04-08 08:52:32 +02:00
|
|
|
private function finalizeStripeMetadata(Customer $customer, User $user, string $stripeSecretKey): void
|
2026-04-01 17:44:57 +02:00
|
|
|
{
|
|
|
|
|
if (null === $customer->getStripeCustomerId() || '' === $stripeSecretKey || 'sk_test_***' === $stripeSecretKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
\Stripe\Stripe::setApiKey($stripeSecretKey);
|
|
|
|
|
\Stripe\Customer::update($customer->getStripeCustomerId(), [
|
2026-04-04 11:17:28 +02:00
|
|
|
'metadata' => [
|
|
|
|
|
'crm_user_id' => (string) $user->getId(),
|
|
|
|
|
'code_comptable' => $customer->getCodeComptable() ?? '',
|
|
|
|
|
],
|
2026-04-01 17:44:57 +02:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:52 +02:00
|
|
|
#[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));
|
|
|
|
|
}
|
|
|
|
|
|
feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
(pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible
Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
+ firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape
Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
|
|
|
#[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])]
|
2026-04-08 15:24:29 +02:00
|
|
|
public function entrepriseSearch(Request $request, \App\Service\EntrepriseSearchService $searchService): JsonResponse
|
feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
(pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible
Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
+ firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape
Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
|
|
|
{
|
2026-04-08 15:24:29 +02:00
|
|
|
return $searchService->search(trim($request->query->getString('q')));
|
feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
(pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible
Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
+ firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape
Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
|
|
|
}
|
|
|
|
|
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
#[Route('/{id}', name: 'show')]
|
2026-04-08 14:47:50 +02:00
|
|
|
public function show(Customer $customer, Request $request, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService, EsyMailDnsService $esyMailDnsService, UserPasswordHasherInterface $passwordHasher, MailerService $mailer, Environment $twig): Response
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
{
|
|
|
|
|
$tab = $request->query->getString('tab', 'info');
|
|
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
if ('POST' === $request->getMethod()) {
|
2026-04-08 09:00:14 +02:00
|
|
|
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]),
|
|
|
|
|
};
|
2026-04-04 21:13:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 17:55:13 +02:00
|
|
|
$this->ensureDefaultContact($customer, $em);
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
$contacts = $em->getRepository(\App\Entity\CustomerContact::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
2026-04-08 08:41:08 +02:00
|
|
|
$domains = $em->getRepository(Domain::class)->findBy(['customer' => $customer]);
|
2026-04-08 14:47:50 +02:00
|
|
|
$domainsInfo = $this->buildDomainsInfo($domains, $em, $esyMailDnsService, 'ndd' === $tab);
|
2026-04-04 21:10:42 +02:00
|
|
|
$websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails
Devis :
- Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval)
- Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public
- Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable
- Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf
- DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events
- DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV
- OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite
- OrderNumber::markAsUnused() ajoute
DocuSeal integration devis :
- DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true)
- WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison)
- DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel
- Page evenements admin /admin/devis/{id}/events avec badges et payload JSON
Signature client :
- DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel)
- Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif)
- Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr
Avis de paiement :
- Entity AdvertLine (pos, title, description, priceHt) liee a Advert
- Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt
- AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch
- AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer"
- OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder)
- Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber
Meilisearch :
- Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state)
- CRUD indexation sur chaque action (create, edit, send, cancel, create-advert)
- Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism
- Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync
Emails :
- MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier
- Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration
- TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev)
Commande NDD :
- app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr
- Cron quotidien 8h (docker + ansible)
Divers :
- Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers)
- VAULT_URL dev = https://kms.esy-web.dev (comme prod)
- app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis
- app.scss : styles drag & drop
- setasign/fpdi-fpdf installe pour fusion PDF
- 5 migrations Doctrine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:35 +02:00
|
|
|
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
|
|
|
|
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
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
|
|
|
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
feat: echeancier de paiement (entites + controller + template + email)
Entites :
- Echeancier : customer, description, totalAmountHt, state (draft/send/
signed/active/completed/cancelled/default), stripeSubscriptionId,
stripePriceId, submitterCompanyId/CustomerId, 3 PDF Vich (unsigned/
signed/audit), submissionId (DocuSeal)
- EcheancierLine : position, amount, scheduledAt, state (prepared/ok/ko),
stripeInvoiceId, paidAt, failureReason
Controller EcheancierController :
- create : cree echeancier avec N echeances mensuelles (montant reparti)
- show : detail echeancier avec progression
- send : envoie email proposition au client
- cancel : annule echeancier + subscription Stripe
- activate : cree Stripe Subscription (price + subscription + cancel_at)
Templates :
- admin/echeancier/show.html.twig : detail avec resume, progression,
tableau echeances, actions (envoyer/activer/annuler)
- admin/clients/show.html.twig : onglet echeancier avec liste + modal creation
- emails/echeancier_proposition.html.twig : email proposition avec detail
Vich mappings : echeancier_pdf, echeancier_signed_pdf, echeancier_audit_pdf
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:31:28 +02:00
|
|
|
$echeancierList = $em->getRepository(\App\Entity\Echeancier::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
$eflexList = $em->getRepository(\App\Entity\EFlex::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
|
|
|
|
|
|
|
|
|
$trustStatus = $this->computeTrustStatus($advertsList, $echeancierList, $customer);
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
|
|
|
|
|
return $this->render('admin/clients/show.html.twig', [
|
|
|
|
|
'customer' => $customer,
|
|
|
|
|
'contacts' => $contacts,
|
|
|
|
|
'domains' => $domains,
|
2026-04-04 20:56:44 +02:00
|
|
|
'domainsInfo' => $domainsInfo,
|
2026-04-04 21:10:42 +02:00
|
|
|
'websites' => $websites,
|
feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails
Devis :
- Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval)
- Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public
- Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable
- Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf
- DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events
- DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV
- OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite
- OrderNumber::markAsUnused() ajoute
DocuSeal integration devis :
- DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true)
- WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison)
- DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel
- Page evenements admin /admin/devis/{id}/events avec badges et payload JSON
Signature client :
- DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel)
- Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif)
- Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr
Avis de paiement :
- Entity AdvertLine (pos, title, description, priceHt) liee a Advert
- Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt
- AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch
- AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer"
- OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder)
- Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber
Meilisearch :
- Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state)
- CRUD indexation sur chaque action (create, edit, send, cancel, create-advert)
- Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism
- Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync
Emails :
- MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier
- Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration
- TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev)
Commande NDD :
- app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr
- Cron quotidien 8h (docker + ansible)
Divers :
- Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers)
- VAULT_URL dev = https://kms.esy-web.dev (comme prod)
- app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis
- app.scss : styles drag & drop
- setasign/fpdi-fpdf installe pour fusion PDF
- 5 migrations Doctrine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:35 +02:00
|
|
|
'devisList' => $devisList,
|
|
|
|
|
'advertsList' => $advertsList,
|
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
|
|
|
'facturesList' => $facturesList,
|
feat: echeancier de paiement (entites + controller + template + email)
Entites :
- Echeancier : customer, description, totalAmountHt, state (draft/send/
signed/active/completed/cancelled/default), stripeSubscriptionId,
stripePriceId, submitterCompanyId/CustomerId, 3 PDF Vich (unsigned/
signed/audit), submissionId (DocuSeal)
- EcheancierLine : position, amount, scheduledAt, state (prepared/ok/ko),
stripeInvoiceId, paidAt, failureReason
Controller EcheancierController :
- create : cree echeancier avec N echeances mensuelles (montant reparti)
- show : detail echeancier avec progression
- send : envoie email proposition au client
- cancel : annule echeancier + subscription Stripe
- activate : cree Stripe Subscription (price + subscription + cancel_at)
Templates :
- admin/echeancier/show.html.twig : detail avec resume, progression,
tableau echeances, actions (envoyer/activer/annuler)
- admin/clients/show.html.twig : onglet echeancier avec liste + modal creation
- emails/echeancier_proposition.html.twig : email proposition avec detail
Vich mappings : echeancier_pdf, echeancier_signed_pdf, echeancier_audit_pdf
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 19:31:28 +02:00
|
|
|
'echeancierList' => $echeancierList,
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
'eflexList' => $eflexList,
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
'tab' => $tab,
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
'trustStatus' => $trustStatus,
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
/**
|
|
|
|
|
* 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<Advert> $adverts
|
|
|
|
|
* @param list<Echeancier> $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'];
|
|
|
|
|
}
|
|
|
|
|
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
private function handleContactForm(Request $request, Customer $customer, EntityManagerInterface $em): Response
|
|
|
|
|
{
|
|
|
|
|
$action = $request->request->getString('contact_action');
|
|
|
|
|
|
|
|
|
|
if ('create' === $action) {
|
fix: SonarQube - extraction ComptaExportService + constantes + CC reduite
ComptaExportService (nouveau service) :
- 14 methodes extraites du ComptabiliteController (29->14 methodes)
- Constantes : LABEL_JOURNAL_VENTES, LABEL_GRAND_LIVRE,
LABEL_COMMISSIONS_STRIPE, DATE_FORMAT_FR, DQL_BETWEEN_DATES,
DQL_IS_PAID, LABEL_CLIENT_DELETED, PREFIX_FACTURE
- resolveCustomerInfo() helper pour deduplication
- groupFactureLinesByType, getServiceGroups, aggregateServiceGroup,
appendPrestataireRows, resolveStatutRentabilite pour CC reduction
- resolveTrancheAge via array lookup (4 returns -> 2)
ComptabiliteController :
- 14 methodes (etait 29), sous la limite de 20
- signCallback CC 25->~10 : extraction downloadSignedDocuments + sendSignedDocumentEmail
- rapportFinancier CC 22->~12 : extraction computeRecettes + computeDepenses
- Suppression $tvaEnabled (deplace dans service)
- CONTENT_DISPOSITION_PREFIX constante
ClientsController :
- 20 methodes : fusion removeContact inline dans handleContactForm
- persistNewContact extrait pour CC reduction
PHPStan level 6 : 0 erreur
PHP CS Fixer : 0 fichier modifie
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:19:16 +02:00
|
|
|
$this->persistNewContact($request, $customer, $em);
|
2026-04-08 09:00:14 +02:00
|
|
|
} elseif ('delete' === $action) {
|
feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
|
|
|
$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']);
|
|
|
|
|
}
|
|
|
|
|
|
fix: SonarQube - extraction ComptaExportService + constantes + CC reduite
ComptaExportService (nouveau service) :
- 14 methodes extraites du ComptabiliteController (29->14 methodes)
- Constantes : LABEL_JOURNAL_VENTES, LABEL_GRAND_LIVRE,
LABEL_COMMISSIONS_STRIPE, DATE_FORMAT_FR, DQL_BETWEEN_DATES,
DQL_IS_PAID, LABEL_CLIENT_DELETED, PREFIX_FACTURE
- resolveCustomerInfo() helper pour deduplication
- groupFactureLinesByType, getServiceGroups, aggregateServiceGroup,
appendPrestataireRows, resolveStatutRentabilite pour CC reduction
- resolveTrancheAge via array lookup (4 returns -> 2)
ComptabiliteController :
- 14 methodes (etait 29), sous la limite de 20
- signCallback CC 25->~10 : extraction downloadSignedDocuments + sendSignedDocumentEmail
- rapportFinancier CC 22->~12 : extraction computeRecettes + computeDepenses
- Suppression $tvaEnabled (deplace dans service)
- CONTENT_DISPOSITION_PREFIX constante
ClientsController :
- 20 methodes : fusion removeContact inline dans handleContactForm
- persistNewContact extrait pour CC reduction
PHPStan level 6 : 0 erreur
PHP CS Fixer : 0 fichier modifie
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:19:16 +02:00
|
|
|
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.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:50:02 +02:00
|
|
|
private function handleDomainForm(Request $request, Customer $customer, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): Response
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
{
|
|
|
|
|
$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);
|
|
|
|
|
|
2026-04-04 20:50:02 +02:00
|
|
|
$this->autoDetectDomain($domain, $ovhService, $cloudflareService, $dnsCheckService);
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
|
|
|
|
|
$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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 20:50:02 +02:00
|
|
|
private function autoDetectDomain(Domain $domain, OvhService $ovhService, CloudflareService $cloudflareService, DnsCheckService $dnsCheckService): void
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
{
|
|
|
|
|
$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) {
|
2026-04-08 08:41:08 +02:00
|
|
|
$domain->setExpiredAt(new \DateTimeImmutable($serviceInfo['expiration']));
|
|
|
|
|
$domain->setUpdatedAt(new \DateTimeImmutable());
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-04 20:50:02 +02:00
|
|
|
|
|
|
|
|
// Fallback RDAP pour l'expiration si pas encore trouvée
|
|
|
|
|
if (null === $domain->getExpiredAt()) {
|
|
|
|
|
$expiration = $dnsCheckService->getExpirationDate($fqdn);
|
|
|
|
|
if (null !== $expiration) {
|
|
|
|
|
$domain->setExpiredAt($expiration);
|
|
|
|
|
}
|
|
|
|
|
}
|
feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 21:19:20 +02:00
|
|
|
private function handleSecurityForm(Request $request, Customer $customer, EntityManagerInterface $em, UserPasswordHasherInterface $passwordHasher, MailerService $mailer, Environment $twig): Response
|
2026-04-04 21:13:37 +02:00
|
|
|
{
|
|
|
|
|
$action = $request->request->getString('security_action');
|
|
|
|
|
$user = $customer->getUser();
|
|
|
|
|
|
2026-04-04 21:19:20 +02:00
|
|
|
if ('send_reset_password' === $action) {
|
|
|
|
|
$tempPassword = bin2hex(random_bytes(8));
|
|
|
|
|
$user->setPassword($passwordHasher->hashPassword($user, $tempPassword));
|
|
|
|
|
$user->setTempPassword($tempPassword);
|
|
|
|
|
$em->flush();
|
2026-04-04 21:13:37 +02:00
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
$setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
|
|
|
$mailer->sendEmail(
|
|
|
|
|
$user->getEmail(),
|
2026-04-08 09:00:14 +02:00
|
|
|
self::WELCOME_SUBJECT,
|
|
|
|
|
$twig->render(self::WELCOME_TEMPLATE, [
|
2026-04-08 08:52:32 +02:00
|
|
|
'firstName' => $user->getFirstName(),
|
|
|
|
|
'email' => $user->getEmail(),
|
|
|
|
|
'setPasswordUrl' => $setPasswordUrl,
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-04-04 21:19:20 +02:00
|
|
|
$this->addFlash('success', 'Un email de reinitialisation a ete envoye a '.$user->getEmail().'.');
|
2026-04-04 21:13:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 18:53:33 +02:00
|
|
|
#[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()]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
$setPasswordUrl = $this->generateUrl('app_set_password', ['token' => $user->getTempPassword()], UrlGeneratorInterface::ABSOLUTE_URL);
|
|
|
|
|
$mailer->sendEmail(
|
|
|
|
|
$user->getEmail(),
|
2026-04-08 09:00:14 +02:00
|
|
|
self::WELCOME_SUBJECT,
|
|
|
|
|
$twig->render(self::WELCOME_TEMPLATE, [
|
2026-04-08 08:52:32 +02:00
|
|
|
'firstName' => $user->getFirstName(),
|
|
|
|
|
'email' => $user->getEmail(),
|
|
|
|
|
'setPasswordUrl' => $setPasswordUrl,
|
|
|
|
|
]),
|
|
|
|
|
);
|
2026-04-04 18:53:33 +02:00
|
|
|
$this->addFlash('success', 'Email de bienvenue renvoye a '.$user->getEmail().'.');
|
|
|
|
|
|
|
|
|
|
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 15:42:52 +02:00
|
|
|
#[Route('/{id}/toggle', name: 'toggle', methods: ['POST'])]
|
2026-04-01 17:44:57 +02:00
|
|
|
public function toggle(Customer $customer, EntityManagerInterface $em, MeilisearchService $meilisearch, LoggerInterface $logger): Response
|
2026-04-01 15:42:52 +02:00
|
|
|
{
|
|
|
|
|
$newState = $customer->isActive() ? Customer::STATE_SUSPENDED : Customer::STATE_ACTIVE;
|
|
|
|
|
$customer->setState($newState);
|
|
|
|
|
$em->flush();
|
|
|
|
|
|
2026-04-08 08:52:32 +02:00
|
|
|
try {
|
|
|
|
|
$meilisearch->indexCustomer($customer);
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
$logger->error('Meilisearch: impossible d\'indexer le client '.$customer->getId().': '.$e->getMessage());
|
|
|
|
|
}
|
2026-04-01 15:42:52 +02:00
|
|
|
|
|
|
|
|
$this->addFlash('success', 'Client '.($customer->isActive() ? 'active' : 'suspendu').'.');
|
|
|
|
|
|
|
|
|
|
return $this->redirectToRoute('app_admin_clients_index');
|
|
|
|
|
}
|
2026-04-04 11:31:55 +02:00
|
|
|
|
|
|
|
|
#[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');
|
|
|
|
|
}
|
feat: systeme complet echeancier SEPA, E-Flex, attestations, avertissements clients
Echeancier - Webhooks DocuSeal:
- Webhook form.completed: telecharge PDF signe + audit, state SIGNED, prepare SEPA, notifie client + admin
- Webhook form.declined: state CANCELLED, notifie client + admin
- Reference EC_ECH_XXXXX affichee dans PDF, emails, pages client, admin
- Attestation fin de paiement auto via DocuSeal au completion
Echeancier - SEPA Direct Debit (remplace Subscriptions):
- Page /echeancier/setup-payment/{id}: formulaire IBAN Stripe Elements + mandat SEPA
- Confirmation SetupIntent -> stocke PaymentMethod -> state ACTIVE
- Commande cron app:echeancier:process-payments: preleve les echeances dues via PaymentIntent off_session
- Webhooks payment_intent.succeeded/failed: met a jour EcheancierLine, notifie client
- Regularisation CB via Stripe Checkout en cas d'echec prelevement
- Bouton "Forcer prelevement" par echeance dans admin
- Infos SEPA stockees (last4, bank_code, country) + affichees admin
- Page setup_payment_done quand SEPA deja configure
- Annulation auto apres 2 rejets + sync paiements vers Advert lie
Echeancier - Lien Advert:
- Champ advert (ManyToOne nullable) sur Echeancier
- Select "Avis lie" dans formulaire creation
- AdvertPayment cree a chaque echeance payee
- Advert passe en accepted quand echeancier completed
Comptabilite:
- Export echeanciers CSV/JSON/PDF/PDF signe dans /admin/comptabilite
- Colonnes: reference, client, creance, majoration, total, paye, restant, Stripe PI, avis lie
Stats:
- Case "Total impaye global" = factures impayees + echeances non payees
- Tableau echeanciers en cours avec restant du
Confiance client:
- Statut Confiant/Attention/Danger calcule dynamiquement
- Badge en haut a droite de la fiche client
- Integre warningLevel (1st=Attention, 2nd=Attention, last=Danger)
- Creation echeancier bloquee si Danger (template + controller)
Avertissements client (tab Controle, ROLE_ROOT):
- 3 niveaux: 1st, 2nd (procedure suspension preparee), last (48h)
- Motifs cochables: impayes, irrespect, hors horaires, services gratuits
- PDF signe DocuSeal pour chaque avertissement (ClientWarningPdf)
- PDF levee avertissement signe (ClientWarningResetPdf)
- Webhooks DocuSeal client_warning + client_warning_reset
- Barre progression 4 etapes dans admin
- Mentions legales: huis clos, contestation direction@e-cosplay.fr
Cloture compte:
- Bouton "Envoyer notification de cloture" apres dernier avertissement
- PDF signe DocuSeal (ClientClosurePdf): suppression 24h, recouvrement, commissaire justice, forces ordre
- Bouton "Suspendre le compte" (state suspended)
- Webhook DocuSeal client_closure: envoie PDF signe a client + admin + direction
Factures:
- Auto-generation PDF si absent lors de l'envoi
- Bouton "Envoyer" visible meme sans PDF pour factures payees
E-Flex (financement services):
- Entites EFlex + EFlexLine (reference E_FLEX_XXXXX)
- Methodes: SEPA, CB (Stripe Checkout), virement manuel
- PDF contrat avec 2 signatures DocuSeal (Company + Client)
- Controller admin CRUD + force payment + paiement manuel
- Pages client: verify, process, sign, signed, setup SEPA, paiement CB
- Webhook DocuSeal eflex: telecharge PDFs, prepare Stripe, notifie
- Webhooks Stripe payment_intent: gestion paiements E-Flex
- Cron traite aussi les E-Flex SEPA dans process-payments
- Tab E-Flex dans fiche client avec liste + modal creation
- Emails: signature, signed, verify_code, echeance_payee, echeance_echec
Attestations custom (ROLE_ROOT):
- Entite AttestationCustom avec items JSON + HMAC SHA-256
- Repeater dynamique pour ajouter elements a attester
- PDF avec phrase officielle "Je soussigne(e)..." + QR code verification
- Signature manuelle dans DocuSeal (redirection)
- Webhook attestation_custom: telecharge PDF signe + audit
- Page publique /attestation/verify/{id}/{hmac} avec validation HMAC
- Lien dans sidebar Super Admin
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:45:22 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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']);
|
|
|
|
|
}
|
2026-04-01 15:42:52 +02:00
|
|
|
}
|