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>
This commit is contained in:
Serreau Jovann
2026-04-07 23:39:31 +02:00
parent 95d33a9a6d
commit 8b35e2b6d2
215 changed files with 11539 additions and 1402 deletions

View File

@@ -81,7 +81,7 @@ class MembresControllerTest extends TestCase
$controller = new MembresController($this->createStub(LoggerInterface::class));
$controller->setContainer($this->createContainer());
$response = $controller->index($this->createKeycloak($users, [['name' => 'siteconseil_member']]), $userRepo);
$response = $controller->index($this->createKeycloak($users, [['name' => 'ecosplay_member']]), $userRepo);
$this->assertSame(200, $response->getStatusCode());
}
@@ -108,7 +108,7 @@ class MembresControllerTest extends TestCase
public function testIndexWithCreatedGroups(): void
{
$kc = $this->createStub(KeycloakAdminService::class);
$kc->method('ensureRequiredGroups')->willReturn(['siteconseil_member', 'siteconseil_admin']);
$kc->method('ensureRequiredGroups')->willReturn(['ecosplay_member', 'ecosplay_admin']);
$kc->method('listUsers')->willReturn([]);
$kc->method('listGroups')->willReturn([]);
@@ -209,7 +209,7 @@ class MembresControllerTest extends TestCase
$twig = $this->createStub(Environment::class);
$twig->method('render')->willReturn('<html>email</html>');
$request = new Request([], ['firstName' => 'Jean', 'lastName' => 'Membre', 'email' => 'jean@test.com', 'groups' => ['siteconseil_member', 'esy-web']]);
$request = new Request([], ['firstName' => 'Jean', 'lastName' => 'Membre', 'email' => 'jean@test.com', 'groups' => ['ecosplay_member', 'esy-web']]);
$request->setMethod('POST');
$controller = new MembresController($this->createStub(LoggerInterface::class));
@@ -230,7 +230,7 @@ class MembresControllerTest extends TestCase
$hasher = $this->createStub(UserPasswordHasherInterface::class);
$hasher->method('hashPassword')->willReturn('hashed');
$request = new Request([], ['firstName' => 'Admin', 'lastName' => 'User', 'email' => 'admin@test.com', 'groups' => ['siteconseil_admin']]);
$request = new Request([], ['firstName' => 'Admin', 'lastName' => 'User', 'email' => 'admin@test.com', 'groups' => ['ecosplay_admin']]);
$request->setMethod('POST');
$controller = new MembresController($this->createStub(LoggerInterface::class));

View File

@@ -225,7 +225,7 @@ class SyncControllerTest extends TestCase
$controller = new SyncController();
$controller->setContainer($this->createContainer());
$response = $controller->syncStripeWebhooks($webhookService, $secretRepo, $em, 'https://crm.siteconseil.fr');
$response = $controller->syncStripeWebhooks($webhookService, $secretRepo, $em, 'https://crm.e-cosplay.fr');
$this->assertSame(302, $response->getStatusCode());
}
@@ -249,7 +249,7 @@ class SyncControllerTest extends TestCase
$controller = new SyncController();
$controller->setContainer($this->createContainer());
$response = $controller->syncStripeWebhooks($webhookService, $secretRepo, $em, 'https://crm.siteconseil.fr/');
$response = $controller->syncStripeWebhooks($webhookService, $secretRepo, $em, 'https://crm.e-cosplay.fr/');
$this->assertSame(302, $response->getStatusCode());
$this->assertSame('whsec_new', $existing->getSecret());
$this->assertSame('we_new', $existing->getEndpointId());

View File

@@ -23,7 +23,7 @@ class CspReportControllerTest extends TestCase
$stack->method('getSession')->willReturn($session);
$paramBag = $this->createStub(\Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface::class);
$paramBag->method('get')->willReturn('admin@siteconseil.fr');
$paramBag->method('get')->willReturn('admin@e-cosplay.fr');
$paramBag->method('has')->willReturn(true);
$container = $this->createStub(ContainerInterface::class);
@@ -73,7 +73,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => 'chrome-extension://abc123',
'blocked-uri' => 'inline',
'document-uri' => 'https://crm.siteconseil.fr',
'document-uri' => 'https://crm.e-cosplay.fr',
'violated-directive' => 'script-src',
],
]);
@@ -91,7 +91,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => 'moz-extension://abc',
'blocked-uri' => '',
'document-uri' => 'https://crm.siteconseil.fr',
'document-uri' => 'https://crm.e-cosplay.fr',
'violated-directive' => 'script-src',
],
]);
@@ -109,7 +109,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => 'http://localhost:3000/app.js',
'blocked-uri' => 'eval',
'document-uri' => 'https://crm.siteconseil.fr',
'document-uri' => 'https://crm.e-cosplay.fr',
'violated-directive' => 'script-src',
],
]);
@@ -143,9 +143,9 @@ class CspReportControllerTest extends TestCase
$payload = json_encode([
'csp-report' => [
'source-file' => 'https://crm.siteconseil.fr/app.js',
'source-file' => 'https://crm.e-cosplay.fr/app.js',
'blocked-uri' => 'wasm-eval',
'document-uri' => 'https://crm.siteconseil.fr',
'document-uri' => 'https://crm.e-cosplay.fr',
'violated-directive' => 'script-src',
],
]);
@@ -163,7 +163,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => '',
'blocked-uri' => 'about:blank',
'document-uri' => 'https://crm.siteconseil.fr',
'document-uri' => 'https://crm.e-cosplay.fr',
'violated-directive' => 'frame-src',
],
]);
@@ -183,7 +183,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => 'https://evil.com/inject.js',
'blocked-uri' => 'https://evil.com',
'document-uri' => 'https://crm.siteconseil.fr/dashboard',
'document-uri' => 'https://crm.e-cosplay.fr/dashboard',
'violated-directive' => 'script-src',
],
]);
@@ -205,7 +205,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => 'https://evil.com/inject.js',
'blocked-uri' => 'https://evil.com',
'document-uri' => 'https://crm.siteconseil.fr/dashboard',
'document-uri' => 'https://crm.e-cosplay.fr/dashboard',
'violated-directive' => 'script-src',
],
]);
@@ -224,7 +224,7 @@ class CspReportControllerTest extends TestCase
$payload = json_encode([
'source-file' => 'https://evil.com/inject.js',
'blocked-uri' => 'https://evil.com',
'document-uri' => 'https://crm.siteconseil.fr/dashboard',
'document-uri' => 'https://crm.e-cosplay.fr/dashboard',
'violated-directive' => 'script-src',
]);
$request = new Request([], [], [], [], [], [], $payload);
@@ -241,7 +241,7 @@ class CspReportControllerTest extends TestCase
'csp-report' => [
'source-file' => '/home/user/node_modules/some-lib/index.js',
'blocked-uri' => 'inline',
'document-uri' => 'https://crm.siteconseil.fr',
'document-uri' => 'https://crm.e-cosplay.fr',
'violated-directive' => 'script-src',
],
]);

View File

@@ -18,7 +18,7 @@ class DomainEmailTest extends TestCase
$user->setLastName('T');
$user->setPassword('h');
return new Domain(new Customer($user), 'siteconseil.fr');
return new Domain(new Customer($user), 'e-cosplay.fr');
}
public function testConstructor(): void
@@ -29,7 +29,7 @@ class DomainEmailTest extends TestCase
$this->assertNull($email->getId());
$this->assertSame($domain, $email->getDomain());
$this->assertSame('contact', $email->getName());
$this->assertSame('contact@siteconseil.fr', $email->getFullEmail());
$this->assertSame('contact@e-cosplay.fr', $email->getFullEmail());
$this->assertSame(DomainEmail::STATE_ACTIVE, $email->getState());
$this->assertTrue($email->isActive());
$this->assertSame(5120, $email->getQuotaMb());
@@ -43,7 +43,7 @@ class DomainEmailTest extends TestCase
$email->setName(' Support ');
$this->assertSame('support', $email->getName());
$this->assertSame('support@siteconseil.fr', $email->getFullEmail());
$this->assertSame('support@e-cosplay.fr', $email->getFullEmail());
$email->setQuotaMb(10240);
$this->assertSame(10240, $email->getQuotaMb());

View File

@@ -40,7 +40,7 @@ class DomainTest extends TestCase
public function testSetters(): void
{
$domain = new Domain($this->createCustomer(), 'siteconseil.fr');
$domain = new Domain($this->createCustomer(), 'e-cosplay.fr');
$domain->setFqdn(' ESY-WEB.DEV ');
$this->assertSame('esy-web.dev', $domain->getFqdn());

View File

@@ -33,7 +33,7 @@ class SubdomainRedirectListenerTest extends TestCase
public function testWebmailRedirect(): void
{
$event = $this->createEvent('webmail.siteconseil.fr');
$event = $this->createEvent('webmail.e-cosplay.fr');
($this->createListener())($event);
$this->assertInstanceOf(RedirectResponse::class, $event->getResponse());
@@ -42,7 +42,7 @@ class SubdomainRedirectListenerTest extends TestCase
public function testStatusRedirect(): void
{
$event = $this->createEvent('status.siteconseil.fr');
$event = $this->createEvent('status.e-cosplay.fr');
($this->createListener())($event);
$this->assertInstanceOf(RedirectResponse::class, $event->getResponse());
@@ -51,7 +51,7 @@ class SubdomainRedirectListenerTest extends TestCase
public function testNoRedirectOnCrmHost(): void
{
$event = $this->createEvent('crm.siteconseil.fr');
$event = $this->createEvent('crm.e-cosplay.fr');
($this->createListener())($event);
$this->assertNull($event->getResponse());
@@ -59,7 +59,7 @@ class SubdomainRedirectListenerTest extends TestCase
public function testNoRedirectWhenAlreadyOnTarget(): void
{
$event = $this->createEvent('webmail.siteconseil.fr', '/webmail');
$event = $this->createEvent('webmail.e-cosplay.fr', '/webmail');
($this->createListener())($event);
$this->assertNull($event->getResponse());
@@ -67,7 +67,7 @@ class SubdomainRedirectListenerTest extends TestCase
public function testNoRedirectOnSubPath(): void
{
$event = $this->createEvent('status.siteconseil.fr', '/status/api');
$event = $this->createEvent('status.e-cosplay.fr', '/status/api');
($this->createListener())($event);
$this->assertNull($event->getResponse());
@@ -75,7 +75,7 @@ class SubdomainRedirectListenerTest extends TestCase
public function testSubRequestIgnored(): void
{
$request = Request::create('https://webmail.siteconseil.fr/');
$request = Request::create('https://webmail.e-cosplay.fr/');
$kernel = $this->createStub(HttpKernelInterface::class);
$event = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST);

View File

@@ -76,7 +76,7 @@ class KeycloakAuthenticatorTest extends TestCase
'email' => 'test@example.com',
'given_name' => 'John',
'family_name' => 'Doe',
'groups' => ['siteconseil_admin'],
'groups' => ['superadmin'],
]);
$client->method('fetchUserFromToken')->willReturn($keycloakUser);

View File

@@ -36,7 +36,7 @@ class TwoFactorCodeMailerTest extends TestCase
$this->mailer->expects($this->once())
->method('send')
->with($this->callback(function (Email $email) {
return $email->getFrom()[0]->getAddress() === 'contact@siteconseil.fr'
return $email->getFrom()[0]->getAddress() === 'contact@e-cosplay.fr'
&& $email->getTo()[0]->getAddress() === 'test@example.com'
&& $email->getSubject() === 'CRM SITECONSEIL - Code de verification'
&& $email->getHtmlBody() === '<html>123456</html>';

View File

@@ -150,11 +150,11 @@ class KeycloakAdminServiceTest extends TestCase
public function testListGroups(): void
{
$resp = $this->setupMocks('GET', '/groups', 200, [['id' => 'g1', 'name' => 'siteconseil_admin'], ['id' => 'g2', 'name' => 'esy-web']]);
$resp = $this->setupMocks('GET', '/groups', 200, [['id' => 'g1', 'name' => 'ecosplay_admin'], ['id' => 'g2', 'name' => 'esy-web']]);
$this->setupCallback([['method' => 'GET', 'url' => '/groups', 'response' => $resp]]);
$groups = $this->service->listGroups();
$this->assertCount(2, $groups);
$this->assertSame('siteconseil_admin', $groups[0]['name']);
$this->assertSame('ecosplay_admin', $groups[0]['name']);
}
public function testCreateGroup(): void
@@ -174,8 +174,8 @@ class KeycloakAdminServiceTest extends TestCase
public function testGetRequiredGroups(): void
{
$groups = KeycloakAdminService::getRequiredGroups();
$this->assertContains('siteconseil_admin', $groups);
$this->assertContains('siteconseil_member', $groups);
$this->assertContains('ecosplay_admin', $groups);
$this->assertContains('ecosplay_member', $groups);
$this->assertContains('esy-web', $groups);
$this->assertContains('esy-mail', $groups);
$this->assertCount(15, $groups);
@@ -193,7 +193,7 @@ class KeycloakAdminServiceTest extends TestCase
public function testEnsureRequiredGroupsCreatesMissing(): void
{
$listResp = $this->setupMocks('GET', '/groups', 200, [['id' => 'g1', 'name' => 'siteconseil_admin']]);
$listResp = $this->setupMocks('GET', '/groups', 200, [['id' => 'g1', 'name' => 'ecosplay_admin']]);
$createResp = $this->setupMocks('POST', '/groups', 201);
$this->setupCallback([
['method' => 'GET', 'url' => '/groups', 'response' => $listResp],
@@ -202,7 +202,7 @@ class KeycloakAdminServiceTest extends TestCase
$created = $this->service->ensureRequiredGroups();
$this->assertNotEmpty($created);
$this->assertNotContains('siteconseil_admin', $created);
$this->assertNotContains('ecosplay_admin', $created);
}
public function testTokenCaching(): void

View File

@@ -16,8 +16,8 @@ describe('app.js DOMContentLoaded', () => {
describe('Member/Admin checkboxes', () => {
beforeEach(() => {
document.body.innerHTML = `
<input type="checkbox" name="groups[]" value="siteconseil_member" checked>
<input type="checkbox" name="groups[]" value="siteconseil_admin">
<input type="checkbox" name="groups[]" value="gp_member" checked>
<input type="checkbox" name="groups[]" value="superadmin">
<input type="checkbox" name="groups[]" value="esy-web">
<input type="checkbox" name="groups[]" value="esy-mail">
`
@@ -26,8 +26,8 @@ describe('app.js DOMContentLoaded', () => {
it('unchecks other groups when member is checked', async () => {
await loadApp()
const member = document.querySelector('[value="siteconseil_member"]')
const admin = document.querySelector('[value="siteconseil_admin"]')
const member = document.querySelector('[value="gp_member"]')
const admin = document.querySelector('[value="superadmin"]')
const esyWeb = document.querySelector('[value="esy-web"]')
admin.checked = true
@@ -43,8 +43,8 @@ describe('app.js DOMContentLoaded', () => {
it('checks all groups and unchecks member when admin is checked', async () => {
await loadApp()
const member = document.querySelector('[value="siteconseil_member"]')
const admin = document.querySelector('[value="siteconseil_admin"]')
const member = document.querySelector('[value="gp_member"]')
const admin = document.querySelector('[value="superadmin"]')
const esyWeb = document.querySelector('[value="esy-web"]')
admin.checked = true
@@ -57,8 +57,8 @@ describe('app.js DOMContentLoaded', () => {
it('does nothing when admin is unchecked', async () => {
await loadApp()
const member = document.querySelector('[value="siteconseil_member"]')
const admin = document.querySelector('[value="siteconseil_admin"]')
const member = document.querySelector('[value="gp_member"]')
const admin = document.querySelector('[value="superadmin"]')
member.checked = true
admin.checked = false