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>
21
.env
@@ -56,7 +56,7 @@ STRIPE_WEBHOOK_SECRET_CONNECT=
|
||||
STRIPE_MODE=test
|
||||
STRIPE_FEE_RATE=0.015
|
||||
STRIPE_FEE_FIXED=25
|
||||
ADMIN_EMAIL=contact@siteconseil.fr
|
||||
ADMIN_EMAIL=contact@e-cosplay.fr
|
||||
|
||||
###> SonarQube ###
|
||||
SONARQUBE_URL=https://sn.esy-web.dev
|
||||
@@ -64,12 +64,12 @@ SONARQUBE_BADGE_TOKEN=sqb_bf06d32640147db064c99d2e893ca63a072630d7
|
||||
SONARQUBE_PROJECT_KEY=crm_siteconseil
|
||||
###< SonarQube ###
|
||||
|
||||
###> SSO SITECONSEIL (Keycloak OIDC) ###
|
||||
###> SSO E-Cosplay (Keycloak OIDC) ###
|
||||
OAUTH_KEYCLOAK_CLIENT_ID=crm_siteconseil
|
||||
OAUTH_KEYCLOAK_CLIENT_SECRET=kh1WBbnEzcEZVriXmU7IaxizChReHmIx
|
||||
OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev
|
||||
OAUTH_KEYCLOAK_REALM=master
|
||||
###< SSO SITECONSEIL (Keycloak OIDC) ###
|
||||
###< SSO E-Cosplay (Keycloak OIDC) ###
|
||||
|
||||
###> Keycloak Admin Service Account ###
|
||||
KEYCLOAK_ADMIN_CLIENT_ID=crm_siteconseil_admin
|
||||
@@ -132,3 +132,18 @@ OVH_CUSTOMER=
|
||||
VAULT_URL=
|
||||
VAULT_TOKEN=
|
||||
###< vault ###
|
||||
|
||||
###> google-search-console ###
|
||||
GOOGLE_SEARCH_CONSOLE_KEY=
|
||||
###< google-search-console ###
|
||||
|
||||
###> sentry ###
|
||||
SENTRY_AUTH_TOKEN=
|
||||
SENTRY_ORG=
|
||||
SENTRY_API_URL=https://sentry.io/api/0
|
||||
###< sentry ###
|
||||
|
||||
###> tva ###
|
||||
TVA_ENABLED=false
|
||||
TVA_RATE=0.20
|
||||
###< tva ###
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
cat > /tmp/discord.json <<DEOF
|
||||
{
|
||||
"embeds": [{
|
||||
"title": "DEPOT CRM SITECONSEIL",
|
||||
"title": "DEPOT CRM E-Cosplay",
|
||||
"description": "${COMMIT_MSG_ESC}",
|
||||
"url": "${COMMIT_URL}",
|
||||
"color": 16156748,
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
{"name": "Auteur", "value": "${AUTHOR}", "inline": true},
|
||||
{"name": "Statistiques", "value": "\`\`\`${FILES_CHANGED_ESC}\`\`\`", "inline": false},
|
||||
{"name": "Fichiers modifies (${FILE_COUNT})", "value": "\`\`\`${FILES_LIST_ESC}\`\`\`", "inline": false},
|
||||
{"name": "Applications SITECONSEIL", "value": "[CRM](https://crm.siteconseil.fr) - Plateforme de gestion clients/revendeurs\\n[Signature](https://sign.siteconseil.fr) - Signature electronique DocuSeal\\n[Paiement](https://payment.siteconseil.fr) - Portail de paiement Stripe\\n[Status](https://status.siteconseil.fr) - Page de status des services\\n[Stripe Connect](https://stripe.siteconseil.fr) - Webhooks Stripe et redirection dashboard Connect", "inline": false}
|
||||
{"name": "Applications E-Cosplay", "value": "[CRM](https://crm.e-cosplay.fr) - Plateforme de gestion clients/revendeurs\\n[Signature](https://sign.e-cosplay.fr) - Signature electronique DocuSeal\\n[Paiement](https://payment.e-cosplay.fr) - Portail de paiement Stripe\\n[Status](https://status.e-cosplay.fr) - Page de status des services\\n[Stripe Connect](https://stripe.e-cosplay.fr) - Webhooks Stripe et redirection dashboard Connect", "inline": false}
|
||||
],
|
||||
"footer": {"text": "${REPO_NAME}"},
|
||||
"timestamp": "${TIMESTAMP}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
crm.siteconseil.fr {
|
||||
crm.e-cosplay.fr {
|
||||
tls {
|
||||
dns cloudflare cfat_rIHZqzCm9GKK3xVnQDNGfu6J91TseIDdTKeuWSFUdf6ccd31
|
||||
}
|
||||
@@ -48,6 +48,6 @@ crm.siteconseil.fr {
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/ticket.siteconseil.fr.log
|
||||
output file /var/log/caddy/ticket.e-cosplay.fr.log
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,14 @@
|
||||
job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:ndd:check --env=prod >> /var/log/crm-siteconseil-ndd-check.log 2>&1"
|
||||
user: bot
|
||||
|
||||
- name: Configure payment reminder cron (daily at 9am)
|
||||
cron:
|
||||
name: "crm-siteconseil payment reminder"
|
||||
minute: "0"
|
||||
hour: "9"
|
||||
job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:payment:reminder --env=prod >> /var/log/crm-siteconseil-payment-reminder.log 2>&1"
|
||||
user: bot
|
||||
|
||||
- name: Configure Meilisearch full reindex cron (weekly Sunday at 4am)
|
||||
cron:
|
||||
name: "crm-siteconseil meilisearch reindex"
|
||||
|
||||
@@ -5,10 +5,10 @@ MESSENGER_TRANSPORT_DSN=redis://:{{ redis_password }}@redis:6379/messages
|
||||
SESSION_HANDLER_DSN=redis://:{{ redis_password }}@redis:6379/1
|
||||
REDIS_CACHE_DSN=redis://:{{ redis_password }}@redis:6379/2
|
||||
MAILER_DSN={{ mailer_dsn }}
|
||||
DEFAULT_URI=https://ticket.siteconseil.fr
|
||||
DEFAULT_URI=https://ticket.e-cosplay.fr
|
||||
VITE_LOAD=1
|
||||
REAL_MAIL=1
|
||||
OUTSIDE_URL=https://ticket.siteconseil.fr
|
||||
OUTSIDE_URL=https://ticket.e-cosplay.fr
|
||||
STRIPE_PK={{ stripe_pk }}
|
||||
STRIPE_SK={{ stripe_sk }}
|
||||
STRIPE_WEBHOOK_SECRET={{ stripe_webhook_secret }}
|
||||
@@ -20,12 +20,12 @@ MEILISEARCH_API_KEY={{ meilisearch_api_key }}
|
||||
SONARQUBE_URL=https://sn.esy-web.dev
|
||||
SONARQUBE_BADGE_TOKEN={{ sonarqube_badge_token }}
|
||||
SONARQUBE_PROJECT_KEY=crm_siteconseil
|
||||
OAUTH_KEYCLOAK_CLIENT_ID=crm_siteconseil
|
||||
OAUTH_KEYCLOAK_CLIENT_SECRET=kh1WBbnEzcEZVriXmU7IaxizChReHmIx
|
||||
OAUTH_KEYCLOAK_CLIENT_ID=crm_ecosplay
|
||||
OAUTH_KEYCLOAK_CLIENT_SECRET=QiksEpHqDCHFPMM9CWb3RHfag31VJfIV
|
||||
OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev
|
||||
OAUTH_KEYCLOAK_REALM=master
|
||||
OAUTH_KEYCLOAK_REALM=e-cosplay
|
||||
SECRET_ANALYTICS={{ analytics_secret }}
|
||||
KEYCLOAK_ADMIN_CLIENT_ID=crm_siteconseil_admin
|
||||
KEYCLOAK_ADMIN_CLIENT_ID=crm-ecosplay-admin
|
||||
KEYCLOAK_ADMIN_CLIENT_SECRET={{ keycloak_admin_client_secret }}
|
||||
AWS_PK={{ aws_pk }}
|
||||
AWS_SECRET={{ aws_secret }}
|
||||
@@ -33,8 +33,10 @@ AWS_REGION=eu-west-3
|
||||
CLOUDFLARE_KEY={{ cloudflare_key }}
|
||||
MAILCOW_URL=https://mail.esy-web.dev
|
||||
MAILCOW_API_KEY={{ mailcow_api_key }}
|
||||
WEBHOOK_BASE_URL=https://stripe.siteconseil.fr
|
||||
WEBHOOK_BASE_URL=https://stripe.e-cosplay.fr
|
||||
DOCUSEAL_URL=https://signature.esy-web.dev
|
||||
DOCUSEAL_API={{ docuseal_api }}
|
||||
DOCUSEAL_WEBHOOKS_SECRET_HEADER=X-Sign
|
||||
DOCUSEAL_WEBHOOKS_SECRET={{ docuseal_webhooks_secret }}
|
||||
TVA_ENABLED={{ tva_enabled }}
|
||||
TVA_RATE={{ tva_rate }}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
127.0.0.1 ansible_user=bot ansible_become=yes ansible_become_method=sudo ansible_connection=local
|
||||
|
||||
[production:vars]
|
||||
deploy_path=/var/www/crm.siteconseil/
|
||||
deploy_path=/var/www/crm.e-cosplay/
|
||||
|
||||
@@ -23,13 +23,15 @@ discord_webhook: https://discord.com/api/webhooks/1419573620602044518/ikAdxWxsrr
|
||||
esymail_hostname: mail.esy-web.dev
|
||||
vault_url: https://kms.esy-web.dev
|
||||
vault_token: CHANGE_ME_IN_PROD
|
||||
tva_enabled: 'false'
|
||||
tva_rate: '0.20'
|
||||
ovh_key: 34bc2c2eb416b67d
|
||||
ovh_secret: 12239d273975b5ab53318907fb66d355
|
||||
ovh_customer: 56c387eb9ca4b9a2de4d4d97fd3d7f22
|
||||
smime_private_key: |
|
||||
Bag Attributes
|
||||
localKeyID: 75 15 E3 C2 1D 7B 61 75 99 B9 22 D8 FD A4 19 AC 6B BE 1F 8F
|
||||
friendlyName: contact@siteconseil.fr
|
||||
friendlyName: contact@e-cosplay.fr
|
||||
Key Attributes: <No Attributes>
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC60+PtobUKQsjH
|
||||
|
||||
326
assets/app.js
@@ -3,8 +3,8 @@ import { initEntrepriseSearch } from "./modules/entreprise-search.js"
|
||||
|
||||
// Membre / Super Admin : mutuellement exclusif
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const memberCheckbox = document.querySelector('input[value="siteconseil_member"]');
|
||||
const adminCheckbox = document.querySelector('input[value="siteconseil_admin"]');
|
||||
const memberCheckbox = document.querySelector('input[value="gp_member"]');
|
||||
const adminCheckbox = document.querySelector('input[value="superadmin"]');
|
||||
const otherGroupCheckboxes = () =>
|
||||
[...document.querySelectorAll('input[name="groups[]"]')].filter(cb => cb !== memberCheckbox);
|
||||
|
||||
@@ -35,8 +35,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
confirmModal.className = 'hidden fixed inset-0 z-[100] flex items-center justify-center';
|
||||
confirmModal.innerHTML = `
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" id="confirm-overlay"></div>
|
||||
<div class="relative glass-heavy w-full max-w-md mx-4 overflow-hidden" style="border-radius: 16px;">
|
||||
<div class="glass-dark text-white px-6 py-4 flex items-center gap-3" style="border-radius: 16px 16px 0 0;">
|
||||
<div class="relative glass-heavy w-full max-w-md mx-4 overflow-hidden">
|
||||
<div class="glass-dark text-white px-6 py-4 flex items-center gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
@@ -46,7 +46,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<p id="confirm-message" class="text-sm font-medium text-gray-700 leading-relaxed"></p>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" id="confirm-cancel" class="px-5 py-2 glass font-bold uppercase text-xs tracking-wider text-gray-700 hover:bg-gray-900 hover:text-white transition-all">Annuler</button>
|
||||
<button type="button" id="confirm-ok" class="px-5 py-2 bg-red-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-red-700 transition-all" style="border-radius: 6px;">Confirmer</button>
|
||||
<button type="button" id="confirm-ok" class="px-5 py-2 bg-red-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-red-700 transition-all">Confirmer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -274,9 +274,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// ──────── Stripe Payment Modal ────────
|
||||
initStripePayment();
|
||||
|
||||
// ──────── Tab search devis / avis ────────
|
||||
initTabSearch('search-devis', 'search-devis-results');
|
||||
initTabSearch('search-adverts', 'search-adverts-results');
|
||||
initTabSearch('search-factures', 'search-factures-results');
|
||||
|
||||
// ──────── Devis lines repeater + drag & drop ────────
|
||||
initDevisLines();
|
||||
@@ -287,8 +291,224 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (refuseBtn && refuseForm) {
|
||||
refuseBtn.addEventListener('click', () => refuseForm.classList.toggle('hidden'));
|
||||
}
|
||||
|
||||
// ──────── Modal open/close generique (data-modal-open / data-modal-close) ────────
|
||||
document.querySelectorAll('[data-modal-open]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const modal = document.getElementById(btn.dataset.modalOpen);
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-modal-close]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const modal = document.getElementById(btn.dataset.modalClose);
|
||||
if (modal) modal.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────── Prestataire : recherche SIRET via API entreprise.data.gouv.fr ────────
|
||||
const siretSearchBtn = document.getElementById('siret-search-btn');
|
||||
const siretInput = document.getElementById('siret-search-input');
|
||||
const siretResults = document.getElementById('siret-search-results');
|
||||
if (siretSearchBtn && siretInput && siretResults) {
|
||||
siretSearchBtn.addEventListener('click', () => {
|
||||
const q = siretInput.value.trim();
|
||||
if (q.length < 3) { siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Saisissez au moins 3 caracteres.</p>'; siretResults.classList.remove('hidden'); return; }
|
||||
|
||||
siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Recherche...</p>';
|
||||
siretResults.classList.remove('hidden');
|
||||
|
||||
fetch('/admin/prestataires/entreprise-search?q=' + encodeURIComponent(q))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const results = data.results || [];
|
||||
if (results.length === 0) {
|
||||
siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Aucun resultat.</p>';
|
||||
return;
|
||||
}
|
||||
siretResults.innerHTML = results.map(r => {
|
||||
const siege = r.siege || {};
|
||||
const siret = siege.siret || '';
|
||||
const nom = r.nom_complet || r.nom_raison_sociale || '';
|
||||
const adresse = siege.adresse || '';
|
||||
const cp = siege.code_postal || '';
|
||||
const ville = siege.libelle_commune || '';
|
||||
return '<button type="button" class="siret-result-item block w-full text-left px-3 py-2 hover:bg-white/70 border-b border-white/20 transition-all"'
|
||||
+ ' data-nom="' + nom.replace(/"/g, '"') + '"'
|
||||
+ ' data-siret="' + siret + '"'
|
||||
+ ' data-adresse="' + adresse.replace(/"/g, '"') + '"'
|
||||
+ ' data-cp="' + cp + '"'
|
||||
+ ' data-ville="' + ville.replace(/"/g, '"') + '">'
|
||||
+ '<span class="font-bold text-xs">' + nom + '</span>'
|
||||
+ '<span class="text-[10px] text-gray-400 ml-2">' + siret + '</span>'
|
||||
+ '<br><span class="text-[10px] text-gray-400">' + adresse + ' ' + cp + ' ' + ville + '</span>'
|
||||
+ '</button>';
|
||||
}).join('');
|
||||
|
||||
siretResults.querySelectorAll('.siret-result-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const form = siretSearchBtn.closest('form');
|
||||
if (!form) return;
|
||||
const set = (name, val) => { const el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val; };
|
||||
set('raisonSociale', item.dataset.nom);
|
||||
set('siret', item.dataset.siret);
|
||||
set('address', item.dataset.adresse);
|
||||
set('zipCode', item.dataset.cp);
|
||||
set('city', item.dataset.ville);
|
||||
siretResults.classList.add('hidden');
|
||||
siretInput.value = '';
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
siretResults.innerHTML = '<p class="text-xs text-red-500 p-2">Erreur lors de la recherche.</p>';
|
||||
});
|
||||
});
|
||||
|
||||
siretInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); siretSearchBtn.click(); } });
|
||||
document.addEventListener('click', (e) => { if (!siretResults.contains(e.target) && e.target !== siretInput && e.target !== siretSearchBtn) siretResults.classList.add('hidden'); });
|
||||
}
|
||||
});
|
||||
|
||||
function initStripePayment() {
|
||||
const btnStripe = document.getElementById('btn-stripe');
|
||||
const modal = document.getElementById('stripe-modal');
|
||||
const overlay = document.getElementById('stripe-modal-overlay');
|
||||
const closeBtn = document.getElementById('stripe-modal-close');
|
||||
const payBtn = document.getElementById('stripe-pay-btn');
|
||||
const errorsEl = document.getElementById('stripe-errors');
|
||||
const paymentEl = document.getElementById('stripe-payment-element');
|
||||
|
||||
if (!btnStripe || !modal || !paymentEl) return;
|
||||
|
||||
const pk = btnStripe.dataset.pk;
|
||||
const intentUrl = btnStripe.dataset.intentUrl;
|
||||
const successUrl = btnStripe.dataset.successUrl;
|
||||
const amount = btnStripe.dataset.amount;
|
||||
|
||||
if (!pk) return;
|
||||
|
||||
const stripe = Stripe(pk);
|
||||
let elements = null;
|
||||
let clientSecret = null;
|
||||
let isProcessing = false;
|
||||
|
||||
const showModal = () => modal.classList.remove('hidden');
|
||||
const hideModal = () => { if (!isProcessing) modal.classList.add('hidden'); };
|
||||
|
||||
closeBtn.addEventListener('click', hideModal);
|
||||
overlay.addEventListener('click', hideModal);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideModal(); });
|
||||
|
||||
let currentMethod = 'card';
|
||||
|
||||
function openStripeModal(method) {
|
||||
currentMethod = method;
|
||||
showModal();
|
||||
payBtn.disabled = true;
|
||||
payBtn.textContent = 'Chargement...';
|
||||
errorsEl.classList.add('hidden');
|
||||
|
||||
const bodyPayload = method === 'sepa' ? { method: 'sepa' } : {};
|
||||
|
||||
fetch(intentUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bodyPayload),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
errorsEl.textContent = data.error;
|
||||
errorsEl.classList.remove('hidden');
|
||||
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
|
||||
return;
|
||||
}
|
||||
|
||||
clientSecret = data.clientSecret;
|
||||
|
||||
const elementsOptions = {
|
||||
clientSecret: clientSecret,
|
||||
locale: 'fr',
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
colorPrimary: method === 'sepa' ? '#2563eb' : '#4f46e5',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
elements = stripe.elements(elementsOptions);
|
||||
|
||||
const paymentElement = elements.create('payment');
|
||||
paymentEl.innerHTML = '';
|
||||
paymentElement.mount('#stripe-payment-element');
|
||||
|
||||
paymentElement.on('ready', () => {
|
||||
payBtn.disabled = false;
|
||||
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
|
||||
});
|
||||
|
||||
paymentElement.on('change', (event) => {
|
||||
if (event.error) {
|
||||
errorsEl.textContent = event.error.message;
|
||||
errorsEl.classList.remove('hidden');
|
||||
} else {
|
||||
errorsEl.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
errorsEl.textContent = 'Erreur de connexion au serveur de paiement.';
|
||||
errorsEl.classList.remove('hidden');
|
||||
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
|
||||
});
|
||||
}
|
||||
|
||||
btnStripe.addEventListener('click', () => openStripeModal('card'));
|
||||
|
||||
// Bouton SEPA
|
||||
const btnSepa = document.getElementById('btn-sepa');
|
||||
if (btnSepa) {
|
||||
btnSepa.addEventListener('click', () => openStripeModal('sepa'));
|
||||
}
|
||||
|
||||
payBtn.addEventListener('click', async () => {
|
||||
if (!clientSecret || !elements || isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
payBtn.disabled = true;
|
||||
payBtn.textContent = 'Traitement en cours...';
|
||||
errorsEl.classList.add('hidden');
|
||||
|
||||
// confirmPayment gere automatiquement le 3D Secure (3DS)
|
||||
// Stripe ouvre la modal 3DS dans une iframe si necessaire
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements: elements,
|
||||
confirmParams: {
|
||||
return_url: window.location.origin + successUrl + '?method=' + currentMethod,
|
||||
},
|
||||
});
|
||||
|
||||
// Si on arrive ici, c'est qu'il y a eu une erreur
|
||||
// (en cas de succes, le navigateur est redirige vers return_url)
|
||||
if (error) {
|
||||
if (error.type === 'card_error' || error.type === 'validation_error') {
|
||||
errorsEl.textContent = error.message;
|
||||
} else {
|
||||
errorsEl.textContent = 'Une erreur inattendue est survenue. Veuillez reessayer.';
|
||||
}
|
||||
errorsEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
payBtn.disabled = false;
|
||||
payBtn.textContent = currentMethod === 'sepa' ? 'Prelever ' + amount + ' €' : 'Payer ' + amount + ' €';
|
||||
});
|
||||
}
|
||||
|
||||
function initTabSearch(inputId, resultsId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const results = document.getElementById(resultsId);
|
||||
@@ -394,7 +614,62 @@ function initDevisLines() {
|
||||
|
||||
addBtn.addEventListener('click', () => addLine());
|
||||
|
||||
// Boutons prestations rapides : ajoute une ligne pre-remplie
|
||||
// Validation : empeche l'envoi si un type est selectionne mais pas le service
|
||||
const form = document.getElementById('devis-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', (e) => {
|
||||
const rows = container.querySelectorAll('.line-row');
|
||||
for (const row of rows) {
|
||||
const typeSelect = row.querySelector('.line-type');
|
||||
const serviceSelect = row.querySelector('.line-service-id');
|
||||
if (!typeSelect || !serviceSelect) continue;
|
||||
|
||||
const type = typeSelect.value;
|
||||
if (!type || type === 'hosting' || type === 'maintenance' || type === 'other' || type === 'ndd' || type === 'website') continue;
|
||||
|
||||
// Type avec service obligatoire (esymail) mais pas selectionne — ndd et website autorisent le vide
|
||||
if (!serviceSelect.value && !serviceSelect.disabled && serviceSelect.options.length > 1) {
|
||||
e.preventDefault();
|
||||
serviceSelect.focus();
|
||||
serviceSelect.classList.add('border-red-500', 'ring-2', 'ring-red-300');
|
||||
const pos = row.querySelector('.line-pos')?.textContent || '';
|
||||
alert('Ligne ' + pos + ' : veuillez selectionner le service pour le type "' + typeSelect.options[typeSelect.selectedIndex].text + '".');
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Chargement dynamique des services par type
|
||||
container.addEventListener('change', async (e) => {
|
||||
if (!e.target.classList.contains('line-type')) return;
|
||||
const select = e.target;
|
||||
const row = select.closest('.line-row');
|
||||
const serviceSelect = row.querySelector('.line-service-id');
|
||||
const type = select.value;
|
||||
|
||||
serviceSelect.innerHTML = '<option value="">— Selectionner le service —</option>';
|
||||
serviceSelect.disabled = true;
|
||||
|
||||
if (!type || type === 'hosting' || type === 'maintenance' || type === 'other') return;
|
||||
|
||||
const url = select.dataset.servicesUrl.replace('__TYPE__', type);
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const items = await resp.json();
|
||||
if (items.length > 0) {
|
||||
items.forEach(item => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.id;
|
||||
opt.textContent = item.label;
|
||||
serviceSelect.appendChild(opt);
|
||||
});
|
||||
serviceSelect.disabled = false;
|
||||
}
|
||||
} catch (err) { /* silencieux */ }
|
||||
});
|
||||
|
||||
// Boutons prestations rapides : ajoute une ligne pre-remplie avec type auto
|
||||
document.querySelectorAll('.quick-price-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
addLine();
|
||||
@@ -404,6 +679,18 @@ function initDevisLines() {
|
||||
lastRow.querySelector('textarea[name$="[description]"]').value = btn.dataset.description || '';
|
||||
const priceInput = lastRow.querySelector('.line-price');
|
||||
priceInput.value = btn.dataset.price || '0.00';
|
||||
|
||||
// Auto-set le type de service
|
||||
const lineType = btn.dataset.lineType || '';
|
||||
if (lineType) {
|
||||
const typeSelect = lastRow.querySelector('.line-type');
|
||||
if (typeSelect) {
|
||||
typeSelect.value = lineType;
|
||||
// Trigger change pour charger les services du client
|
||||
typeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
recalc();
|
||||
});
|
||||
});
|
||||
@@ -457,13 +744,38 @@ function initDevisLines() {
|
||||
try {
|
||||
const arr = JSON.parse(initial);
|
||||
arr.sort((a, b) => (a.pos || 0) - (b.pos || 0));
|
||||
arr.forEach(l => {
|
||||
arr.forEach(async (l) => {
|
||||
addLine();
|
||||
const row = container.querySelector('.line-row:last-child');
|
||||
if (!row) return;
|
||||
row.querySelector('input[name$="[title]"]').value = l.title || '';
|
||||
row.querySelector('textarea[name$="[description]"]').value = l.description || '';
|
||||
row.querySelector('.line-price').value = l.priceHt || '0.00';
|
||||
|
||||
// Pre-select type
|
||||
const typeSelect = row.querySelector('.line-type');
|
||||
if (l.type && typeSelect) {
|
||||
typeSelect.value = l.type;
|
||||
|
||||
// Charge les services si type avec serviceId
|
||||
if (l.serviceId && l.type !== 'hosting' && l.type !== 'maintenance' && l.type !== 'other') {
|
||||
const serviceSelect = row.querySelector('.line-service-id');
|
||||
const url = typeSelect.dataset.servicesUrl.replace('__TYPE__', l.type);
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
const items = await resp.json();
|
||||
serviceSelect.innerHTML = '<option value="">— Selectionner le service —</option>';
|
||||
items.forEach(item => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = item.id;
|
||||
opt.textContent = item.label;
|
||||
if (item.id === l.serviceId) opt.selected = true;
|
||||
serviceSelect.appendChild(opt);
|
||||
});
|
||||
serviceSelect.disabled = false;
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
recalc();
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
197
assets/app.scss
@@ -1,147 +1,143 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Glass Design System ─── */
|
||||
/* ─── Neo Brutalist Design System ─── */
|
||||
|
||||
:root {
|
||||
--glass-bg: rgba(255, 255, 255, 0.65);
|
||||
--glass-bg-heavy: rgba(255, 255, 255, 0.85);
|
||||
--glass-border: rgba(255, 255, 255, 0.3);
|
||||
--glass-border-strong: rgba(255, 255, 255, 0.5);
|
||||
--glass-dark: rgba(17, 24, 39, 0.85);
|
||||
--glass-dark-heavy: rgba(17, 24, 39, 0.92);
|
||||
--glass-blur: 16px;
|
||||
--glass-blur-heavy: 24px;
|
||||
--brutal-bg: #ffffff;
|
||||
--brutal-border: 3px solid #111827;
|
||||
--brutal-border-thin: 2px solid #111827;
|
||||
--brutal-shadow: 4px 4px 0px #111827;
|
||||
--brutal-shadow-hover: 6px 6px 0px #111827;
|
||||
--brutal-shadow-active: 2px 2px 0px #111827;
|
||||
--brutal-shadow-gold: 4px 4px 0px #b8860b;
|
||||
--gold: #fabf04;
|
||||
--gold-light: rgba(250, 191, 4, 0.15);
|
||||
--gold-glow: rgba(250, 191, 4, 0.4);
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 6px;
|
||||
--shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-glass-hover: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-gold: 0 4px 24px rgba(250, 191, 4, 0.25);
|
||||
--dark: #111827;
|
||||
--radius: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-xs: 0px;
|
||||
}
|
||||
|
||||
/* ─── Animated background ─── */
|
||||
/* ─── Background ─── */
|
||||
|
||||
body.glass-bg {
|
||||
background: #f0f0f5;
|
||||
background: #f5f5f0;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(250, 191, 4, 0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 50% 80%, rgba(250, 191, 4, 0.05) 0%, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
repeating-linear-gradient(0deg, transparent, transparent 49px, rgba(0,0,0,0.03) 49px, rgba(0,0,0,0.03) 50px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 49px, rgba(0,0,0,0.03) 49px, rgba(0,0,0,0.03) 50px);
|
||||
}
|
||||
|
||||
/* ─── Glass panel ─── */
|
||||
/* ─── Brutalist panels ─── */
|
||||
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
background: var(--brutal-bg);
|
||||
border: var(--brutal-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-glass);
|
||||
box-shadow: var(--brutal-shadow);
|
||||
}
|
||||
|
||||
.glass-heavy {
|
||||
background: var(--glass-bg-heavy);
|
||||
backdrop-filter: blur(var(--glass-blur-heavy));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-heavy));
|
||||
border: 1px solid var(--glass-border-strong);
|
||||
background: var(--brutal-bg);
|
||||
border: var(--brutal-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-glass);
|
||||
box-shadow: var(--brutal-shadow);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background: var(--glass-dark);
|
||||
backdrop-filter: blur(var(--glass-blur-heavy));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-heavy));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--dark);
|
||||
border: var(--brutal-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--brutal-shadow);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.glass-dark-heavy {
|
||||
background: var(--glass-dark-heavy);
|
||||
backdrop-filter: blur(var(--glass-blur-heavy));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-heavy));
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: var(--dark);
|
||||
border: 3px solid #000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.glass-gold {
|
||||
background: rgba(250, 191, 4, 0.12);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid rgba(250, 191, 4, 0.3);
|
||||
background: var(--gold);
|
||||
border: var(--brutal-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-gold);
|
||||
box-shadow: var(--brutal-shadow-gold);
|
||||
}
|
||||
|
||||
/* ─── Glass buttons ─── */
|
||||
/* ─── Brutalist buttons ─── */
|
||||
|
||||
.btn-glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xs);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: var(--brutal-bg);
|
||||
border: var(--brutal-border-thin);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--brutal-shadow);
|
||||
transition: all 0.1s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--glass-bg-heavy);
|
||||
box-shadow: var(--shadow-glass-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--brutal-shadow-hover);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: var(--brutal-shadow-active);
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-gold {
|
||||
background: var(--gold);
|
||||
border: 1px solid rgba(250, 191, 4, 0.6);
|
||||
border-radius: var(--radius-xs);
|
||||
box-shadow: var(--shadow-gold);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: var(--brutal-border-thin);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--brutal-shadow);
|
||||
transition: all 0.1s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 28px rgba(250, 191, 4, 0.4);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--brutal-shadow-hover);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: var(--brutal-shadow-active);
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
background: var(--glass-dark-heavy);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--dark);
|
||||
border: var(--brutal-border-thin);
|
||||
border-radius: var(--radius);
|
||||
color: white;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: var(--brutal-shadow);
|
||||
transition: all 0.1s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(99, 102, 241, 0.85);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
transform: translateY(-1px);
|
||||
background: var(--gold);
|
||||
color: var(--dark);
|
||||
box-shadow: var(--brutal-shadow-hover);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: var(--brutal-shadow-active);
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Glass input ─── */
|
||||
/* ─── Brutalist input ─── */
|
||||
|
||||
.input-glass {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
background: #ffffff;
|
||||
border: var(--brutal-border-thin);
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px var(--gold-light), var(--shadow-glass);
|
||||
box-shadow: 3px 3px 0px var(--gold);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +156,7 @@ body.glass-bg {
|
||||
}
|
||||
|
||||
.heading-page {
|
||||
border-bottom: 2px solid var(--gold);
|
||||
border-bottom: 4px solid var(--gold);
|
||||
display: inline-block;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
@@ -201,15 +197,14 @@ body.glass-bg {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
.admin-sidebar.open { left: 0; }
|
||||
.admin-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
.admin-sidebar.open + .admin-overlay { display: block; }
|
||||
@@ -223,42 +218,45 @@ body.glass-bg {
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.1em;
|
||||
border-radius: 0;
|
||||
transition: all 0.1s ease;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border-left: 4px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border-left-color: var(--gold);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--gold);
|
||||
color: #111827;
|
||||
box-shadow: 0 2px 12px rgba(250, 191, 4, 0.3);
|
||||
border-left-color: #111827;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
&.active-danger {
|
||||
background: rgba(220, 38, 38, 0.8);
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
box-shadow: 0 2px 12px rgba(220, 38, 38, 0.3);
|
||||
border-left-color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Scrollbar styling ─── */
|
||||
|
||||
.admin-sidebar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
width: 6px;
|
||||
}
|
||||
.admin-sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.admin-sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ─── Smooth transitions ─── */
|
||||
@@ -269,4 +267,9 @@ body.glass-bg {
|
||||
|
||||
/* ─── Devis lines drag & drop ─── */
|
||||
.line-row.dragging { opacity: 0.4; }
|
||||
.line-row.drag-over { border-top: 2px solid #fabf04; }
|
||||
.line-row.drag-over { border-top: 4px solid var(--gold); }
|
||||
|
||||
/* ─── Brutalist overrides for Tailwind rounded ─── */
|
||||
.rounded, .rounded-lg, .rounded-xl, .rounded-md, .rounded-sm, .rounded-full {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@ const renderResult = (e, onSelect) => {
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${s.geo_adresse || s.adresse || ''}</div>
|
||||
${d.nom ? '<div class="text-xs text-gray-400 mt-1">Dirigeant : ' + (d.prenoms || '') + ' ' + d.nom + '</div>' : ''}
|
||||
${isAsso ? '<div class="text-xs mt-1"><span class="px-1.5 py-0.5 bg-indigo-100 text-indigo-800 font-bold text-[10px] uppercase" style="border-radius:4px;">Association</span></div>' : ''}
|
||||
${isAsso ? '<div class="text-xs mt-1"><span class="px-1.5 py-0.5 bg-indigo-100 text-indigo-800 font-bold text-[10px] uppercase" >Association</span></div>' : ''}
|
||||
</div>
|
||||
<span class="shrink-0 px-2 py-1 text-[10px] font-bold uppercase ${actif ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}" style="border-radius: 6px;">
|
||||
<span class="shrink-0 px-2 py-1 text-[10px] font-bold uppercase ${actif ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}" >
|
||||
${actif ? 'Actif' : 'Ferme'}
|
||||
</span>
|
||||
</div>`
|
||||
|
||||
@@ -96,4 +96,4 @@ nelmio_security:
|
||||
- auth.esy-web.dev
|
||||
- challenges.cloudflare.com
|
||||
- signature.esy-web.dev
|
||||
- signature.siteconseil.fr
|
||||
- signature.e-cosplay.fr
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pwa:
|
||||
asset_compiler: false # Default to true. Will change to false in 2.0.0.
|
||||
image_processor: 'pwa.image_processor.gd' # Or 'pwa.image_processor.gd'
|
||||
asset_compiler: false
|
||||
image_processor: 'pwa.image_processor.gd'
|
||||
favicons:
|
||||
enabled: true
|
||||
src: '%kernel.project_dir%/public/assets/notif.png'
|
||||
src: '%kernel.project_dir%/public/favicon.png'
|
||||
serviceworker:
|
||||
enabled: true
|
||||
scope: "/"
|
||||
@@ -11,13 +11,13 @@ pwa:
|
||||
skip_waiting: true
|
||||
manifest:
|
||||
enabled: true
|
||||
name: "SITECONSEIL"
|
||||
short_name: "PWA"
|
||||
name: "E-Cosplay"
|
||||
short_name: "E-Cosplay"
|
||||
start_url: "app_home"
|
||||
display: "standalone"
|
||||
background_color: "#ffffff"
|
||||
theme_color: "#4285f4"
|
||||
categories: ['games','multimedia','social networking']
|
||||
background_color: "#f5f5f0"
|
||||
theme_color: "#fabf04"
|
||||
categories: ['business', 'productivity']
|
||||
icons:
|
||||
- src: '%kernel.project_dir%/public/assets/notif.png'
|
||||
sizes: [192]
|
||||
- src: '%kernel.project_dir%/public/favicon.png'
|
||||
sizes: [192, 512]
|
||||
|
||||
@@ -6,6 +6,7 @@ nelmio_security:
|
||||
- 'nonce'
|
||||
- 'https://static.cloudflareinsights.com'
|
||||
- 'https://challenges.cloudflare.com'
|
||||
- 'https://js.stripe.com'
|
||||
|
||||
# Restreindre les soumissions de formulaires à notre domaine
|
||||
# et aux redirections OAuth des plateformes de partage social
|
||||
@@ -16,7 +17,13 @@ nelmio_security:
|
||||
- 'https://twitter.com'
|
||||
|
||||
# Autoriser navigator.share() (Web Share API) et clipboard API
|
||||
# — les deux sont des APIs navigateur natives, pas des appels réseau externes
|
||||
# Ce bloc est présent pour documentation et futures intégrations
|
||||
# Stripe Elements necessite connect-src vers api.stripe.com
|
||||
connect-src:
|
||||
- 'self'
|
||||
- 'https://api.stripe.com'
|
||||
|
||||
# Stripe Elements charge ses iframes depuis js.stripe.com
|
||||
frame-src:
|
||||
- 'self'
|
||||
- 'https://js.stripe.com'
|
||||
- 'https://hooks.stripe.com'
|
||||
|
||||
@@ -5,7 +5,7 @@ scheb_two_factor:
|
||||
|
||||
email:
|
||||
enabled: true
|
||||
sender_email: 'contact@siteconseil.fr'
|
||||
sender_email: 'contact@e-cosplay.fr'
|
||||
sender_name: 'CRM SITECONSEIL'
|
||||
digits: 6
|
||||
template: 'security/2fa_email.html.twig'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
globals:
|
||||
tva_enabled: '%env(bool:TVA_ENABLED)%'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -14,3 +14,11 @@ vich_uploader:
|
||||
uri_prefix: /uploads/adverts
|
||||
upload_destination: '%kernel.project_dir%/public/uploads/adverts'
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
facture_pdf:
|
||||
uri_prefix: /uploads/factures
|
||||
upload_destination: '%kernel.project_dir%/public/uploads/factures'
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
facture_prestataire_pdf:
|
||||
uri_prefix: /uploads/factures_prestataires
|
||||
upload_destination: '%kernel.project_dir%/public/uploads/factures_prestataires'
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
|
||||
@@ -189,12 +189,12 @@ services:
|
||||
image: boky/postfix:latest
|
||||
container_name: crm_siteconseil_postfix
|
||||
restart: unless-stopped
|
||||
hostname: mail.siteconseil.local
|
||||
hostname: mail.e-cosplay.local
|
||||
environment:
|
||||
ALLOWED_SENDER_DOMAINS: siteconseil.fr siteconseil.local
|
||||
ALLOWED_SENDER_DOMAINS: e-cosplay.fr e-cosplay.local
|
||||
RELAYHOST: "[mailpit]:1025"
|
||||
POSTFIX_myhostname: mail.siteconseil.local
|
||||
POSTFIX_mydestination: siteconseil.local, localhost
|
||||
POSTFIX_myhostname: mail.e-cosplay.local
|
||||
POSTFIX_mydestination: e-cosplay.local, localhost
|
||||
POSTFIX_mynetworks: "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16"
|
||||
POSTFIX_smtpd_milters: "inet:rspamd:11332"
|
||||
POSTFIX_non_smtpd_milters: "inet:rspamd:11332"
|
||||
|
||||
@@ -11,3 +11,5 @@
|
||||
0 4 * * 0 echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:meilisearch:setup" >> /proc/1/fd/1 && php /app/bin/console app:meilisearch:setup --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:meilisearch:setup" >> /proc/1/fd/1
|
||||
0 7 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:cloudflare:clean" >> /proc/1/fd/1 && php /app/bin/console app:cloudflare:clean --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:cloudflare:clean" >> /proc/1/fd/1
|
||||
0 8 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:ndd:check" >> /proc/1/fd/1 && php /app/bin/console app:ndd:check --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:ndd:check" >> /proc/1/fd/1
|
||||
0 9 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:payment:reminder" >> /proc/1/fd/1 && php /app/bin/console app:payment:reminder --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:payment:reminder" >> /proc/1/fd/1
|
||||
0 9 5 * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:reminder:factures-prestataire" >> /proc/1/fd/1 && php /app/bin/console app:reminder:factures-prestataire --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:reminder:factures-prestataire" >> /proc/1/fd/1
|
||||
|
||||
35
migrations/Version20260407082003.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407082003 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE payment_reminder (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, step VARCHAR(30) NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, details TEXT DEFAULT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX idx_payment_reminder_advert ON payment_reminder (advert_id)');
|
||||
$this->addSql('CREATE INDEX idx_payment_reminder_step ON payment_reminder (step)');
|
||||
$this->addSql('ALTER TABLE payment_reminder ADD CONSTRAINT FK_F10D28D07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE payment_reminder DROP CONSTRAINT FK_F10D28D07ECCB6');
|
||||
$this->addSql('DROP TABLE payment_reminder');
|
||||
}
|
||||
}
|
||||
44
migrations/Version20260407085302.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407085302 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE action_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, action VARCHAR(50) NOT NULL, entity_id INT DEFAULT NULL, entity_type VARCHAR(50) DEFAULT NULL, message TEXT NOT NULL, context TEXT DEFAULT NULL, severity VARCHAR(20) NOT NULL, previous_state VARCHAR(50) DEFAULT NULL, new_state VARCHAR(50) DEFAULT NULL, success BOOLEAN NOT NULL, error_message TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, customer_id INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX idx_action_log_customer ON action_log (customer_id)');
|
||||
$this->addSql('CREATE INDEX idx_action_log_action ON action_log (action)');
|
||||
$this->addSql('CREATE INDEX idx_action_log_created ON action_log (created_at)');
|
||||
$this->addSql('ALTER TABLE action_log ADD CONSTRAINT FK_B2C5F6859395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE advert_line ADD type VARCHAR(30) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE advert_line ADD service_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE devis_line ADD type VARCHAR(30) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE devis_line ADD service_id INT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE action_log DROP CONSTRAINT FK_B2C5F6859395C3F3');
|
||||
$this->addSql('DROP TABLE action_log');
|
||||
$this->addSql('ALTER TABLE advert_line DROP type');
|
||||
$this->addSql('ALTER TABLE advert_line DROP service_id');
|
||||
$this->addSql('ALTER TABLE devis_line DROP type');
|
||||
$this->addSql('ALTER TABLE devis_line DROP service_id');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260407105246.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407105246 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE advert ADD stripe_payment_id VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE advert DROP stripe_payment_id');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260407120747.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407120747 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE advert_payment (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(20) NOT NULL, amount NUMERIC(10, 2) NOT NULL, method VARCHAR(30) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX idx_advert_payment_advert ON advert_payment (advert_id)');
|
||||
$this->addSql('CREATE INDEX idx_advert_payment_type ON advert_payment (type)');
|
||||
$this->addSql('ALTER TABLE advert_payment ADD CONSTRAINT FK_C766C45BD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE advert_payment DROP CONSTRAINT FK_C766C45BD07ECCB6');
|
||||
$this->addSql('DROP TABLE advert_payment');
|
||||
}
|
||||
}
|
||||
58
migrations/Version20260407121419.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407121419 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE facture_line (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, pos INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, price_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, type VARCHAR(30) DEFAULT NULL, service_id INT DEFAULT NULL, facture_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_3712983A7F2DEE08 ON facture_line (facture_id)');
|
||||
$this->addSql('ALTER TABLE facture_line ADD CONSTRAINT FK_3712983A7F2DEE08 FOREIGN KEY (facture_id) REFERENCES facture (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE facture ADD state VARCHAR(20) DEFAULT \'created\' NOT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD total_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD total_tva NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD total_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD is_paid BOOLEAN NOT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD paid_method VARCHAR(30) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD facture_pdf VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD customer_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE facture ADD CONSTRAINT FK_FE8664109395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_FE8664109395C3F3 ON facture (customer_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE facture_line DROP CONSTRAINT FK_3712983A7F2DEE08');
|
||||
$this->addSql('DROP TABLE facture_line');
|
||||
$this->addSql('ALTER TABLE facture DROP CONSTRAINT FK_FE8664109395C3F3');
|
||||
$this->addSql('DROP INDEX IDX_FE8664109395C3F3');
|
||||
$this->addSql('ALTER TABLE facture DROP state');
|
||||
$this->addSql('ALTER TABLE facture DROP total_ht');
|
||||
$this->addSql('ALTER TABLE facture DROP total_tva');
|
||||
$this->addSql('ALTER TABLE facture DROP total_ttc');
|
||||
$this->addSql('ALTER TABLE facture DROP is_paid');
|
||||
$this->addSql('ALTER TABLE facture DROP paid_at');
|
||||
$this->addSql('ALTER TABLE facture DROP paid_method');
|
||||
$this->addSql('ALTER TABLE facture DROP facture_pdf');
|
||||
$this->addSql('ALTER TABLE facture DROP updated_at');
|
||||
$this->addSql('ALTER TABLE facture DROP customer_id');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260407202410.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407202410 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE advert_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, details TEXT DEFAULT NULL, ip VARCHAR(45) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, advert_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX idx_advert_event_advert ON advert_event (advert_id)');
|
||||
$this->addSql('CREATE INDEX idx_advert_event_type ON advert_event (type)');
|
||||
$this->addSql('ALTER TABLE advert_event ADD CONSTRAINT FK_338A17CBD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE advert_event DROP CONSTRAINT FK_338A17CBD07ECCB6');
|
||||
$this->addSql('DROP TABLE advert_event');
|
||||
}
|
||||
}
|
||||
37
migrations/Version20260407213024.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260407213024 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE facture_prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, num_facture VARCHAR(100) NOT NULL, montant_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, montant_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL, year SMALLINT NOT NULL, month SMALLINT NOT NULL, is_paid BOOLEAN NOT NULL, paid_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, facture_pdf VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, prestataire_id INT NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_6725BF76BE3DB2B7 ON facture_prestataire (prestataire_id)');
|
||||
$this->addSql('CREATE INDEX idx_facture_presta_period ON facture_prestataire (prestataire_id, year, month)');
|
||||
$this->addSql('CREATE TABLE prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, raison_sociale VARCHAR(255) NOT NULL, siret VARCHAR(14) DEFAULT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(20) DEFAULT NULL, address VARCHAR(500) DEFAULT NULL, zip_code VARCHAR(10) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, state VARCHAR(20) DEFAULT \'active\' NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('ALTER TABLE facture_prestataire ADD CONSTRAINT FK_6725BF76BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE facture_prestataire DROP CONSTRAINT FK_6725BF76BE3DB2B7');
|
||||
$this->addSql('DROP TABLE facture_prestataire');
|
||||
$this->addSql('DROP TABLE prestataire');
|
||||
}
|
||||
}
|
||||
BIN
public/JOAFE_PDF_Unitaire_20250013_00029.pdf
Normal file
BIN
public/avis-94312151700016-20260401111834.pdf
Normal file
BIN
public/cgv.pdf
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 2.7 MiB |
BIN
public/logo.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 3.1 MiB |
BIN
public/rib.pdf
Normal file
@@ -18,11 +18,11 @@ use Twig\Environment;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:dns:check',
|
||||
description: 'Verifie la configuration DNS, AWS SES, Cloudflare et Mailcow pour les domaines SITECONSEIL',
|
||||
description: 'Verifie la configuration DNS, AWS SES, Cloudflare et Mailcow pour les domaines E-Cosplay',
|
||||
)]
|
||||
class CheckDnsCommand extends Command
|
||||
{
|
||||
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
|
||||
private const MONITOR_EMAIL = 'monitor@e-cosplay.fr';
|
||||
|
||||
public function __construct(
|
||||
private DnsCheckService $dnsCheck,
|
||||
@@ -41,7 +41,7 @@ class CheckDnsCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Verification DNS - CRM SITECONSEIL');
|
||||
$io->title('Verification DNS - CRM E-Cosplay');
|
||||
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
@@ -367,15 +367,15 @@ class CheckDnsCommand extends Command
|
||||
$hasWarnings = [] !== $warnings;
|
||||
|
||||
if ($hasErrors) {
|
||||
$subject = 'CRM SITECONSEIL - Alerte DNS : '.\count($errors).' erreur(s)';
|
||||
$subject = 'CRM E-Cosplay - Alerte DNS : '.\count($errors).' erreur(s)';
|
||||
$statusColor = '#dc2626';
|
||||
$statusText = \count($errors).' ERREUR(S) DETECTEE(S)';
|
||||
} elseif ($hasWarnings) {
|
||||
$subject = 'CRM SITECONSEIL - DNS : '.\count($warnings).' avertissement(s)';
|
||||
$subject = 'CRM E-Cosplay - DNS : '.\count($warnings).' avertissement(s)';
|
||||
$statusColor = '#f59e0b';
|
||||
$statusText = \count($warnings).' AVERTISSEMENT(S)';
|
||||
} else {
|
||||
$subject = 'CRM SITECONSEIL - DNS : configuration OK';
|
||||
$subject = 'CRM E-Cosplay - DNS : configuration OK';
|
||||
$statusColor = '#16a34a';
|
||||
$statusText = 'TOUTES LES VERIFICATIONS OK';
|
||||
}
|
||||
@@ -446,10 +446,10 @@ class CheckDnsCommand extends Command
|
||||
$this->httpClient->request('POST', $this->discordWebhook, [
|
||||
'json' => [
|
||||
'embeds' => [[
|
||||
'title' => 'Esy-Infra : '.$title,
|
||||
'title' => 'E-Infra : '.$title,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'footer' => ['text' => 'Esy-Infra - Monitoring DNS'],
|
||||
'footer' => ['text' => 'E-Infra - Monitoring DNS'],
|
||||
'timestamp' => (new \DateTimeImmutable())->format('c'),
|
||||
]],
|
||||
],
|
||||
|
||||
@@ -20,7 +20,7 @@ use Twig\Environment;
|
||||
class CheckNddCommand extends Command
|
||||
{
|
||||
private const EXPIRATION_THRESHOLD_DAYS = 30;
|
||||
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
|
||||
private const MONITOR_EMAIL = 'monitor@e-cosplay.fr';
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
|
||||
195
src/Command/PaymentReminderCommand.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\PaymentReminder;
|
||||
use App\Service\ActionService;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Twig\Environment;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:payment:reminder',
|
||||
description: 'Envoie les relances de paiement aux clients selon l\'echeancier (15j, 10j, 5j, 3j, 1j, mise en demeure, resiliation)',
|
||||
)]
|
||||
class PaymentReminderCommand extends Command
|
||||
{
|
||||
private const MONITOR_EMAIL = 'notification@e-cosplay.fr';
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private MailerService $mailer,
|
||||
private Environment $twig,
|
||||
private LoggerInterface $logger,
|
||||
private ActionService $actionService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Relances de paiement');
|
||||
|
||||
// Recupere tous les avis en etat "send" (envoyes, en attente de paiement)
|
||||
$adverts = $this->em->getRepository(Advert::class)->findBy(['state' => Advert::STATE_SEND]);
|
||||
|
||||
if ([] === $adverts) {
|
||||
$io->success('Aucun avis de paiement en attente.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$sent = 0;
|
||||
|
||||
foreach ($adverts as $advert) {
|
||||
$customer = $advert->getCustomer();
|
||||
if (null === $customer || null === $customer->getEmail()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysSinceSend = (int) $now->diff($advert->getUpdatedAt() ?? $advert->getCreatedAt())->days;
|
||||
$nextStep = $this->getNextStep($advert, $daysSinceSend);
|
||||
|
||||
if (null === $nextStep) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stepConfig = PaymentReminder::STEPS_CONFIG[$nextStep];
|
||||
$numOrder = $advert->getOrderNumber()->getNumOrder();
|
||||
|
||||
$io->text(sprintf(' %s -> %s (%d j) -> %s', $numOrder, $customer->getEmail(), $daysSinceSend, $stepConfig['label']));
|
||||
|
||||
try {
|
||||
// Execute l'action associee a l'etape
|
||||
$this->executeStep($advert, $nextStep, $customer);
|
||||
|
||||
// Enregistre la relance
|
||||
$reminder = new PaymentReminder($advert, $nextStep, 'Jour '.$daysSinceSend.' - '.$stepConfig['label']);
|
||||
$this->em->persist($reminder);
|
||||
$this->em->flush();
|
||||
|
||||
++$sent;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('PaymentReminder: erreur '.$numOrder.' step '.$nextStep.': '.$e->getMessage());
|
||||
$io->warning('Erreur '.$numOrder.' : '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$io->success($sent.' relance(s) envoyee(s) sur '.\count($adverts).' avis en attente.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine la prochaine etape de relance non encore envoyee.
|
||||
*/
|
||||
private function getNextStep(Advert $advert, int $daysSinceSend): ?string
|
||||
{
|
||||
$existingSteps = $this->em->getRepository(PaymentReminder::class)
|
||||
->createQueryBuilder('r')
|
||||
->select('r.step')
|
||||
->where('r.advert = :advert')
|
||||
->setParameter('advert', $advert)
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
|
||||
foreach (PaymentReminder::STEPS_CONFIG as $step => $config) {
|
||||
if ($daysSinceSend >= $config['days'] && !\in_array($step, $existingSteps, true)) {
|
||||
return $step;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function executeStep(Advert $advert, string $step, mixed $customer): void
|
||||
{
|
||||
$numOrder = $advert->getOrderNumber()->getNumOrder();
|
||||
|
||||
match ($step) {
|
||||
PaymentReminder::STEP_REMINDER_15 => $this->sendReminder($advert, $customer, 'Rappel de paiement', 'reminder'),
|
||||
PaymentReminder::STEP_WARNING_10 => $this->sendReminder($advert, $customer, 'Rappel + avertissement', 'warning'),
|
||||
PaymentReminder::STEP_SUSPENSION_WARNING_5 => $this->sendReminder($advert, $customer, 'Avertissement - Suspension des services imminente', 'suspension_warning'),
|
||||
PaymentReminder::STEP_FINAL_REMINDER_3 => $this->sendReminder($advert, $customer, 'Ultime rappel avant suspension', 'final_reminder'),
|
||||
PaymentReminder::STEP_SUSPENSION_1 => $this->handleSuspension($advert, $customer),
|
||||
PaymentReminder::STEP_FORMAL_NOTICE => $this->handleFormalNotice($advert, $customer),
|
||||
PaymentReminder::STEP_TERMINATION_WARNING => $this->handleTerminationWarning($advert, $customer),
|
||||
PaymentReminder::STEP_TERMINATION => $this->handleTermination($advert, $customer),
|
||||
};
|
||||
|
||||
// Notification admin pour chaque etape
|
||||
$this->mailer->sendEmail(
|
||||
self::MONITOR_EMAIL,
|
||||
'Relance '.$numOrder.' - '.PaymentReminder::STEPS_CONFIG[$step]['label'].' - '.$customer->getFullName(),
|
||||
$this->twig->render('emails/payment_reminder_admin.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'step' => $step,
|
||||
'stepLabel' => PaymentReminder::STEPS_CONFIG[$step]['label'],
|
||||
'severity' => PaymentReminder::STEPS_CONFIG[$step]['severity'],
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private function sendReminder(Advert $advert, mixed $customer, string $subject, string $type): void
|
||||
{
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
$subject.' - Avis '.$advert->getOrderNumber()->getNumOrder(),
|
||||
$this->twig->render('emails/payment_reminder_client.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'type' => $type,
|
||||
'subject' => $subject,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private function handleSuspension(Advert $advert, mixed $customer): void
|
||||
{
|
||||
$this->sendReminder($advert, $customer, 'Suspension de vos services', 'suspension');
|
||||
|
||||
// Suspension effective via ActionService (suspend client + sites + emails)
|
||||
$this->actionService->suspendCustomer($customer, 'Impaye avis '.$advert->getOrderNumber()->getNumOrder());
|
||||
}
|
||||
|
||||
private function handleFormalNotice(Advert $advert, mixed $customer): void
|
||||
{
|
||||
$this->sendReminder($advert, $customer, 'Mise en demeure de paiement', 'formal_notice');
|
||||
}
|
||||
|
||||
private function handleTerminationWarning(Advert $advert, mixed $customer): void
|
||||
{
|
||||
$this->sendReminder($advert, $customer, 'Avertissement - Resiliation du contrat et suppression de vos donnees', 'termination_warning');
|
||||
|
||||
// Desactive le client definitivement
|
||||
$this->actionService->disableCustomer($customer, 'Pre-resiliation impaye avis '.$advert->getOrderNumber()->getNumOrder());
|
||||
}
|
||||
|
||||
private function handleTermination(Advert $advert, mixed $customer): void
|
||||
{
|
||||
$this->sendReminder($advert, $customer, 'Resiliation de contrat et mise en recouvrement', 'termination');
|
||||
|
||||
// Marque le client pour suppression (cron nocturne supprimera les donnees)
|
||||
$this->actionService->markForDeletion($customer, 'Resiliation + recouvrement avis '.$advert->getOrderNumber()->getNumOrder());
|
||||
|
||||
// Annule l'avis
|
||||
$advert->setState(Advert::STATE_CANCEL);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
88
src/Command/ReminderFacturesPrestataireCommand.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Prestataire;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:reminder:factures-prestataire',
|
||||
description: 'Rappel mensuel pour saisir les factures prestataires du mois precedent',
|
||||
)]
|
||||
class ReminderFacturesPrestataireCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private MailerService $mailer,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
// Mois precedent
|
||||
$prev = $now->modify('first day of last month');
|
||||
$year = (int) $prev->format('Y');
|
||||
$month = (int) $prev->format('n');
|
||||
$monthNames = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
|
||||
$monthLabel = $monthNames[$month].' '.$year;
|
||||
|
||||
$prestataires = $this->em->getRepository(Prestataire::class)->findBy(['state' => Prestataire::STATE_ACTIVE]);
|
||||
|
||||
$missing = [];
|
||||
foreach ($prestataires as $presta) {
|
||||
$hasFacture = false;
|
||||
foreach ($presta->getFactures() as $f) {
|
||||
if ($f->getYear() === $year && $f->getMonth() === $month) {
|
||||
$hasFacture = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasFacture) {
|
||||
$missing[] = $presta;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($missing)) {
|
||||
$io->success('Toutes les factures prestataires de '.$monthLabel.' sont saisies.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$lines = [];
|
||||
foreach ($missing as $p) {
|
||||
$lines[] = '- '.$p->getRaisonSociale().' ('.$p->getSiret().')';
|
||||
}
|
||||
|
||||
$html = '<p>Bonjour,</p>'
|
||||
.'<p>Les factures prestataires suivantes pour <strong>'.$monthLabel.'</strong> n\'ont pas encore ete saisies :</p>'
|
||||
.'<ul style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #374151; line-height: 22px;">';
|
||||
foreach ($missing as $p) {
|
||||
$html .= '<li><strong>'.$p->getRaisonSociale().'</strong> '.($p->getSiret() ? '('.$p->getSiret().')' : '').'</li>';
|
||||
}
|
||||
$html .= '</ul>'
|
||||
.'<p>Merci de les ajouter dans le CRM : <a href="https://crm.e-cosplay.fr/admin/prestataires" style="color: #fabf04; font-weight: bold;">Espace Prestataires</a></p>';
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$this->mailer->getAdminEmail(),
|
||||
'Rappel : factures prestataires '.$monthLabel.' manquantes',
|
||||
$html,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
|
||||
$io->warning(\count($missing).' facture(s) prestataire(s) manquante(s) pour '.$monthLabel.'. Email de rappel envoye.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,8 @@ class TestMailCommand extends Command
|
||||
$forceDsn = $input->getOption('force-dsn');
|
||||
|
||||
$subject = 'prod' === $env
|
||||
? '[PROD] CRM SITECONSEIL - Email de test production'
|
||||
: '[DEV] CRM SITECONSEIL - Email de test developpement';
|
||||
? '[PROD] CRM E-Cosplay - Email de test production'
|
||||
: '[DEV] CRM E-Cosplay - Email de test developpement';
|
||||
|
||||
$io->title('Envoi du mail de test ('.$env.')');
|
||||
$io->text('Destinataire : '.$email);
|
||||
@@ -83,7 +83,7 @@ class TestMailCommand extends Command
|
||||
$directMailer = new Mailer($transport);
|
||||
|
||||
$email = (new Email())
|
||||
->from('SARL SITECONSEIL <contact@siteconseil.fr>')
|
||||
->from('Association E-Cosplay <contact@e-cosplay.fr>')
|
||||
->to($to)
|
||||
->subject($subject)
|
||||
->html($html);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\AdvertEvent;
|
||||
use App\Service\FactureService;
|
||||
use App\Service\MailerService;
|
||||
use App\Service\MeilisearchService;
|
||||
use App\Service\Pdf\AdvertPdf;
|
||||
@@ -27,15 +29,34 @@ class AdvertController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): Response
|
||||
#[Route('/{id}/events', name: 'events', requirements: ['id' => '\d+'], methods: ['GET'])]
|
||||
public function events(int $id): Response
|
||||
{
|
||||
$advert = $this->em->getRepository(Advert::class)->find($id);
|
||||
if (null === $advert) {
|
||||
throw $this->createNotFoundException('Avis de paiement introuvable');
|
||||
}
|
||||
|
||||
$pdf = new AdvertPdf($kernel, $advert, $urlGenerator);
|
||||
$events = $this->em->getRepository(AdvertEvent::class)->findBy(
|
||||
['advert' => $advert],
|
||||
['createdAt' => 'DESC']
|
||||
);
|
||||
|
||||
return $this->render('admin/advert/events.html.twig', [
|
||||
'advert' => $advert,
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator, \Twig\Environment $twig): Response
|
||||
{
|
||||
$advert = $this->em->getRepository(Advert::class)->find($id);
|
||||
if (null === $advert) {
|
||||
throw $this->createNotFoundException('Avis de paiement introuvable');
|
||||
}
|
||||
|
||||
$pdf = new AdvertPdf($kernel, $advert, $urlGenerator, $twig);
|
||||
$pdf->generate();
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'advert_').'.pdf';
|
||||
@@ -140,6 +161,9 @@ class AdvertController extends AbstractController
|
||||
|
||||
$this->meilisearch->indexAdvert($advert);
|
||||
|
||||
$this->em->persist(new AdvertEvent($advert, AdvertEvent::TYPE_MAIL_SEND, 'Avis envoye a '.$customer->getEmail()));
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Avis de paiement envoye a '.$customer->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
@@ -207,6 +231,9 @@ class AdvertController extends AbstractController
|
||||
$attachments,
|
||||
);
|
||||
|
||||
$this->em->persist(new AdvertEvent($advert, AdvertEvent::TYPE_MAIL_SEND, 'Rappel envoye a '.$customer->getEmail()));
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Avis de paiement renvoye a '.$customer->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
@@ -228,6 +255,129 @@ class AdvertController extends AbstractController
|
||||
return $this->json($results);
|
||||
}
|
||||
|
||||
#[Route('/{id}/create-facture', name: 'create_facture', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function createFacture(int $id, FactureService $factureService): Response
|
||||
{
|
||||
$advert = $this->em->getRepository(Advert::class)->find($id);
|
||||
if (null === $advert) {
|
||||
throw $this->createNotFoundException('Avis de paiement introuvable');
|
||||
}
|
||||
|
||||
if (Advert::STATE_ACCEPTED !== $advert->getState()) {
|
||||
$this->addFlash('error', 'L\'avis doit etre paye pour creer une facture.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $advert->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'avis',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($advert->getFactures()->count() > 0) {
|
||||
$this->addFlash('error', 'Une facture existe deja pour cet avis.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $advert->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'avis',
|
||||
]);
|
||||
}
|
||||
|
||||
$facture = $factureService->createFromAdvert($advert);
|
||||
|
||||
$this->addFlash('success', 'Facture '.$facture->getInvoiceNumber().' creee.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $advert->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'avis',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/sync-payment', name: 'sync_payment', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function syncPayment(
|
||||
int $id,
|
||||
FactureService $factureService,
|
||||
#[\Symfony\Component\DependencyInjection\Attribute\Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||
): Response {
|
||||
$advert = $this->em->getRepository(Advert::class)->find($id);
|
||||
if (null === $advert) {
|
||||
throw $this->createNotFoundException('Avis de paiement introuvable');
|
||||
}
|
||||
|
||||
$piId = $advert->getStripePaymentId();
|
||||
if (null === $piId || '' === $stripeSk) {
|
||||
$this->addFlash('error', 'Pas de PaymentIntent ou Stripe non configure.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $advert->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'avis',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
\Stripe\Stripe::setApiKey($stripeSk);
|
||||
$pi = \Stripe\PaymentIntent::retrieve($piId);
|
||||
|
||||
if ('succeeded' === $pi->status) {
|
||||
$amount = number_format($pi->amount_received / 100, 2, '.', '');
|
||||
$metadata = $pi->metadata instanceof \Stripe\StripeObject ? $pi->metadata->toArray() : (array) ($pi->metadata ?? []);
|
||||
$method = $metadata['payment_method'] ?? ($pi->payment_method_types[0] ?? 'card');
|
||||
|
||||
$methodLabel = match ($method) {
|
||||
'sepa_debit' => 'Prelevement SEPA',
|
||||
'customer_balance' => 'Virement bancaire (Stripe)',
|
||||
'paypal' => 'PayPal',
|
||||
'klarna' => 'Klarna',
|
||||
'revolut_pay' => 'Revolut Pay',
|
||||
'amazon_pay' => 'Amazon Pay',
|
||||
'link' => 'Link (Stripe)',
|
||||
default => 'Carte bancaire',
|
||||
};
|
||||
|
||||
// Maj etat advert
|
||||
if (Advert::STATE_ACCEPTED !== $advert->getState()) {
|
||||
$advert->setState(Advert::STATE_ACCEPTED);
|
||||
}
|
||||
|
||||
// Creer AdvertPayment si pas deja present
|
||||
$existingPayment = $this->em->getRepository(\App\Entity\AdvertPayment::class)
|
||||
->findOneBy(['advert' => $advert, 'type' => \App\Entity\AdvertPayment::TYPE_SUCCESS]);
|
||||
|
||||
if (null === $existingPayment) {
|
||||
$payment = new \App\Entity\AdvertPayment($advert, \App\Entity\AdvertPayment::TYPE_SUCCESS, $amount);
|
||||
$payment->setMethod($method);
|
||||
$this->em->persist($payment);
|
||||
}
|
||||
|
||||
// Generer la facture si pas deja presente
|
||||
if ($advert->getFactures()->count() === 0) {
|
||||
$factureService->createPaidFactureFromAdvert($advert, $amount, $methodLabel);
|
||||
} else {
|
||||
// Mettre a jour la facture existante
|
||||
$facture = $advert->getFactures()->first();
|
||||
if (false !== $facture && !$facture->isPaid()) {
|
||||
$facture->setIsPaid(true);
|
||||
$facture->setPaidAt(new \DateTimeImmutable());
|
||||
$facture->setPaidMethod($methodLabel);
|
||||
$facture->setState(\App\Entity\Facture::STATE_PAID);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->meilisearch->indexAdvert($advert);
|
||||
|
||||
$this->addFlash('success', 'Sync Stripe OK : avis '.$advert->getOrderNumber()->getNumOrder().' paye ('.$methodLabel.', '.$amount.' EUR).');
|
||||
} else {
|
||||
$this->addFlash('warning', 'PaymentIntent statut : '.$pi->status.' (pas encore succeeded).');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('error', 'Erreur sync Stripe : '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $advert->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'avis',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function cancel(int $id): Response
|
||||
{
|
||||
|
||||
@@ -83,7 +83,11 @@ class ClientsController extends AbstractController
|
||||
$this->finalizeStripeCustomer($customer, $user, $stripeSecretKey);
|
||||
|
||||
$codeComptable = trim($request->request->getString('codeComptable'));
|
||||
$customer->setCodeComptable('' !== $codeComptable ? '411_'.$codeComptable : $customerRepository->generateUniqueCodeComptable($customer));
|
||||
if ('' !== $codeComptable) {
|
||||
$customer->setCodeComptable(str_starts_with($codeComptable, 'EC-') ? $codeComptable : 'EC-'.$codeComptable);
|
||||
} else {
|
||||
$customer->setCodeComptable($customerRepository->generateUniqueCodeComptable($customer));
|
||||
}
|
||||
$em->flush();
|
||||
|
||||
$this->indexInMeilisearch($meilisearch, $customer, $logger);
|
||||
@@ -201,7 +205,7 @@ class ClientsController extends AbstractController
|
||||
|
||||
$mailer->sendEmail(
|
||||
$user->getEmail(),
|
||||
'CRM SITECONSEIL - Bienvenue dans votre espace client',
|
||||
'CRM E-Cosplay - Bienvenue dans votre espace client',
|
||||
$twig->render('emails/client_created.html.twig', [
|
||||
'firstName' => $user->getFirstName(),
|
||||
'email' => $user->getEmail(),
|
||||
@@ -388,6 +392,7 @@ class ClientsController extends AbstractController
|
||||
$websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||
$devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||
$advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||
$facturesList = $em->getRepository(\App\Entity\Facture::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||
|
||||
return $this->render('admin/clients/show.html.twig', [
|
||||
'customer' => $customer,
|
||||
@@ -397,6 +402,7 @@ class ClientsController extends AbstractController
|
||||
'websites' => $websites,
|
||||
'devisList' => $devisList,
|
||||
'advertsList' => $advertsList,
|
||||
'facturesList' => $facturesList,
|
||||
'tab' => $tab,
|
||||
]);
|
||||
}
|
||||
|
||||
1113
src/Controller/Admin/ComptabiliteController.php
Normal file
@@ -55,6 +55,18 @@ class DashboardController extends AbstractController
|
||||
$results[] = ['type' => 'revendeur', 'label' => $h['fullName'] ?? $h['raisonSociale'] ?? '', 'sub' => $h['codeRevendeur'] ?? '', 'url' => '/admin/revendeurs/'.($h['id'] ?? 0).'/edit'];
|
||||
}
|
||||
|
||||
foreach ($meilisearch->searchDevis($q, 3) as $h) {
|
||||
$results[] = ['type' => 'devis', 'label' => $h['numOrder'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=devis'];
|
||||
}
|
||||
|
||||
foreach ($meilisearch->searchAdverts($q, 3) as $h) {
|
||||
$results[] = ['type' => 'avis', 'label' => $h['numOrder'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=avis'];
|
||||
}
|
||||
|
||||
foreach ($meilisearch->searchFactures($q, 3) as $h) {
|
||||
$results[] = ['type' => 'facture', 'label' => $h['invoiceNumber'] ?? '', 'sub' => ($h['customerName'] ?? '').' — '.($h['totalTtc'] ?? '0').' EUR', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=factures'];
|
||||
}
|
||||
|
||||
return new JsonResponse($results);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ use Twig\Environment;
|
||||
#[IsGranted('ROLE_EMPLOYE')]
|
||||
class DevisController extends AbstractController
|
||||
{
|
||||
private const TVA_RATE = 0.20;
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private OrderNumberService $orderNumberService,
|
||||
@@ -41,6 +39,42 @@ class DevisController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/services/{customerId}/{type}', name: 'services', requirements: ['customerId' => '\d+'], methods: ['GET'])]
|
||||
public function services(int $customerId, string $type): Response
|
||||
{
|
||||
$customer = $this->em->getRepository(Customer::class)->find($customerId);
|
||||
if (null === $customer) {
|
||||
return $this->json([]);
|
||||
}
|
||||
|
||||
$items = match ($type) {
|
||||
'ndd' => array_map(
|
||||
fn ($d) => ['id' => $d->getId(), 'label' => $d->getFqdn()],
|
||||
$this->em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer])
|
||||
),
|
||||
'website' => array_map(
|
||||
fn ($w) => ['id' => $w->getId(), 'label' => $w->getName().' ('.$w->getType().')'],
|
||||
$this->em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer])
|
||||
),
|
||||
'esymail' => array_map(
|
||||
function ($domain) {
|
||||
$emails = $this->em->getRepository(\App\Entity\DomainEmail::class)->findBy(['domain' => $domain]);
|
||||
|
||||
return array_map(fn ($e) => ['id' => $e->getId(), 'label' => $e->getFullEmail()], $emails);
|
||||
},
|
||||
$this->em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer])
|
||||
),
|
||||
default => [],
|
||||
};
|
||||
|
||||
// Flatten esymail (array of arrays)
|
||||
if ('esymail' === $type) {
|
||||
$items = array_merge(...$items);
|
||||
}
|
||||
|
||||
return $this->json($items);
|
||||
}
|
||||
|
||||
#[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])]
|
||||
public function search(int $customerId, Request $request): Response
|
||||
{
|
||||
@@ -157,6 +191,14 @@ class DevisController extends AbstractController
|
||||
if ('' !== $description) {
|
||||
$line->setDescription($description);
|
||||
}
|
||||
$lineType = trim((string) ($data['type'] ?? ''));
|
||||
if ('' !== $lineType) {
|
||||
$line->setType($lineType);
|
||||
}
|
||||
$lineServiceId = (int) ($data['serviceId'] ?? 0);
|
||||
if ($lineServiceId > 0) {
|
||||
$line->setServiceId($lineServiceId);
|
||||
}
|
||||
|
||||
$this->em->persist($line);
|
||||
$devis->addLine($line);
|
||||
@@ -173,12 +215,11 @@ class DevisController extends AbstractController
|
||||
: $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]);
|
||||
}
|
||||
|
||||
$totalTva = round($totalHt * self::TVA_RATE, 2);
|
||||
$totalTtc = round($totalHt + $totalTva, 2);
|
||||
$totals = $this->devisService->computeTotals(number_format($totalHt, 2, '.', ''));
|
||||
|
||||
$devis->setTotalHt(number_format($totalHt, 2, '.', ''));
|
||||
$devis->setTotalTva(number_format($totalTva, 2, '.', ''));
|
||||
$devis->setTotalTtc(number_format($totalTtc, 2, '.', ''));
|
||||
$devis->setTotalHt($totals['totalHt']);
|
||||
$devis->setTotalTva($totals['totalTva']);
|
||||
$devis->setTotalTtc($totals['totalTtc']);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -193,7 +234,7 @@ class DevisController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function generatePdf(int $id, KernelInterface $kernel): Response
|
||||
public function generatePdf(int $id, KernelInterface $kernel, \Twig\Environment $twig): Response
|
||||
{
|
||||
$devis = $this->em->getRepository(Devis::class)->find($id);
|
||||
if (null === $devis) {
|
||||
@@ -201,7 +242,7 @@ class DevisController extends AbstractController
|
||||
}
|
||||
|
||||
// Generation du PDF (devis + CGV fusionnees via FPDI)
|
||||
$pdf = new DevisPdf($kernel, $devis);
|
||||
$pdf = new DevisPdf($kernel, $devis, $twig);
|
||||
$pdf->generate();
|
||||
|
||||
// Ecriture dans un fichier temporaire
|
||||
@@ -478,6 +519,12 @@ class DevisController extends AbstractController
|
||||
if (null !== $devisLine->getDescription()) {
|
||||
$advertLine->setDescription($devisLine->getDescription());
|
||||
}
|
||||
if (null !== $devisLine->getType()) {
|
||||
$advertLine->setType($devisLine->getType());
|
||||
}
|
||||
if (null !== $devisLine->getServiceId()) {
|
||||
$advertLine->setServiceId($devisLine->getServiceId());
|
||||
}
|
||||
$this->em->persist($advertLine);
|
||||
$advert->addLine($advertLine);
|
||||
}
|
||||
|
||||
162
src/Controller/Admin/FactureController.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\Facture;
|
||||
use App\Service\MailerService;
|
||||
use App\Service\MeilisearchService;
|
||||
use App\Service\Pdf\FacturePdf;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Twig\Environment;
|
||||
|
||||
#[Route('/admin/facture', name: 'app_admin_facture_')]
|
||||
#[IsGranted('ROLE_EMPLOYE')]
|
||||
class FactureController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private MeilisearchService $meilisearch,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])]
|
||||
public function search(int $customerId, Request $request): JsonResponse
|
||||
{
|
||||
$query = trim($request->query->getString('q'));
|
||||
if ('' === $query) {
|
||||
return $this->json([]);
|
||||
}
|
||||
|
||||
return $this->json($this->meilisearch->searchFactures($query, 20, $customerId));
|
||||
}
|
||||
|
||||
#[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator, Environment $twig): Response
|
||||
{
|
||||
$facture = $this->em->getRepository(Facture::class)->find($id);
|
||||
if (null === $facture) {
|
||||
throw $this->createNotFoundException('Facture introuvable');
|
||||
}
|
||||
|
||||
$pdf = new FacturePdf($kernel, $facture, $urlGenerator, $twig);
|
||||
$pdf->generate();
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf';
|
||||
$pdf->Output('F', $tmpPath);
|
||||
|
||||
$hadOld = null !== $facture->getFacturePdf();
|
||||
$uploadDir = $kernel->getProjectDir().'/public/uploads/factures';
|
||||
|
||||
if ($hadOld) {
|
||||
$oldPath = $uploadDir.'/'.$facture->getFacturePdf();
|
||||
if (file_exists($oldPath)) {
|
||||
@unlink($oldPath);
|
||||
}
|
||||
$facture->setFacturePdf(null);
|
||||
}
|
||||
|
||||
$uploadedFile = new UploadedFile(
|
||||
$tmpPath,
|
||||
'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf',
|
||||
'application/pdf',
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
$facture->setFacturePdfFile($uploadedFile);
|
||||
$facture->setUpdatedAt(new \DateTimeImmutable());
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@unlink($tmpPath);
|
||||
|
||||
$this->addFlash('success', 'PDF facture '.$facture->getInvoiceNumber().' '.($hadOld ? 'regenere' : 'genere').'.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $facture->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'factures',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function send(
|
||||
int $id,
|
||||
MailerService $mailer,
|
||||
Environment $twig,
|
||||
UrlGeneratorInterface $urlGenerator,
|
||||
#[Autowire('%kernel.project_dir%')] string $projectDir,
|
||||
): Response {
|
||||
$facture = $this->em->getRepository(Facture::class)->find($id);
|
||||
if (null === $facture) {
|
||||
throw $this->createNotFoundException('Facture introuvable');
|
||||
}
|
||||
|
||||
if (null === $facture->getFacturePdf()) {
|
||||
$this->addFlash('error', 'Le PDF doit etre genere avant l\'envoi.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $facture->getCustomer()?->getId() ?? 0,
|
||||
'tab' => 'factures',
|
||||
]);
|
||||
}
|
||||
|
||||
$customer = $facture->getCustomer();
|
||||
if (null === $customer || null === $customer->getEmail()) {
|
||||
$this->addFlash('error', 'Client ou email introuvable.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $customer?->getId() ?? 0,
|
||||
'tab' => 'factures',
|
||||
]);
|
||||
}
|
||||
|
||||
$invoiceNumber = $facture->getInvoiceNumber();
|
||||
|
||||
$attachments = [];
|
||||
$pdfPath = $projectDir.'/public/uploads/factures/'.$facture->getFacturePdf();
|
||||
if (file_exists($pdfPath)) {
|
||||
$attachments[] = ['path' => $pdfPath, 'name' => 'facture-'.str_replace('/', '-', $invoiceNumber).'.pdf'];
|
||||
}
|
||||
|
||||
$verifyUrl = $urlGenerator->generate('app_facture_verify', [
|
||||
'id' => $facture->getId(),
|
||||
'hmac' => $facture->getHmac(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$html = $twig->render('emails/facture_send.html.twig', [
|
||||
'customer' => $customer,
|
||||
'facture' => $facture,
|
||||
'verifyUrl' => $verifyUrl,
|
||||
]);
|
||||
|
||||
$mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Facture '.$invoiceNumber,
|
||||
$html,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
$attachments,
|
||||
);
|
||||
|
||||
$facture->setState(Facture::STATE_SEND);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Facture '.$invoiceNumber.' envoyee a '.$customer->getEmail().'.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', [
|
||||
'id' => $customer->getId(),
|
||||
'tab' => 'factures',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ class LogsController extends AbstractController
|
||||
|
||||
$hmacValid = $loggerService->verifyLog($log);
|
||||
|
||||
$logoPath = $projectDir.'/public/logo_facture.png';
|
||||
$logoPath = $projectDir.'/public/logo.jpg';
|
||||
$logo = file_exists($logoPath) ? 'data:image/png;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
|
||||
|
||||
$verifyUrl = $this->generateUrl('app_log_verify', [
|
||||
|
||||
@@ -159,7 +159,7 @@ class MembresController extends AbstractController
|
||||
$user->setFirstName($data['firstName']);
|
||||
$user->setLastName($data['lastName']);
|
||||
$user->setKeycloakId($data['keycloakId']);
|
||||
$user->setRoles(\in_array('siteconseil_admin', $data['groups'], true) ? ['ROLE_ROOT'] : ['ROLE_EMPLOYE']);
|
||||
$user->setRoles(\in_array('superadmin', $data['groups'], true) ? ['ROLE_ROOT'] : ['ROLE_EMPLOYE']);
|
||||
$user->setPassword($passwordHasher->hashPassword($user, $data['tempPassword']));
|
||||
$user->setTempPassword($data['tempPassword']);
|
||||
|
||||
@@ -183,7 +183,7 @@ class MembresController extends AbstractController
|
||||
): void {
|
||||
$mailer->sendEmail(
|
||||
$email,
|
||||
'CRM SITECONSEIL - Votre compte a ete cree',
|
||||
'CRM E-Cosplay - Votre compte a ete cree',
|
||||
$twig->render('emails/membre_created.html.twig', [
|
||||
'firstName' => $firstName,
|
||||
'lastName' => $lastName,
|
||||
@@ -214,7 +214,7 @@ class MembresController extends AbstractController
|
||||
|
||||
$mailer->sendEmail(
|
||||
$user->getEmail(),
|
||||
'CRM SITECONSEIL - Rappel : votre compte a ete cree',
|
||||
'CRM E-Cosplay - Rappel : votre compte a ete cree',
|
||||
$twig->render('emails/membre_created.html.twig', [
|
||||
'firstName' => $user->getFirstName(),
|
||||
'lastName' => $user->getLastName(),
|
||||
|
||||
197
src/Controller/Admin/PrestatairesController.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\FacturePrestataire;
|
||||
use App\Entity\Prestataire;
|
||||
use App\Repository\PrestataireRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
#[Route('/admin/prestataires', name: 'app_admin_prestataires_')]
|
||||
#[IsGranted('ROLE_ROOT')]
|
||||
class PrestatairesController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'index')]
|
||||
public function index(PrestataireRepository $repo): Response
|
||||
{
|
||||
$prestataires = $repo->findBy([], ['raisonSociale' => 'ASC']);
|
||||
|
||||
return $this->render('admin/prestataires/index.html.twig', [
|
||||
'prestataires' => $prestataires,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/create', name: 'create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
$raisonSociale = trim($request->request->getString('raisonSociale'));
|
||||
|
||||
if ('' === $raisonSociale) {
|
||||
$this->addFlash('error', 'La raison sociale est obligatoire.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_index');
|
||||
}
|
||||
|
||||
$prestataire = new Prestataire($raisonSociale);
|
||||
$prestataire->setSiret($request->request->getString('siret') ?: null);
|
||||
$prestataire->setEmail($request->request->getString('email') ?: null);
|
||||
$prestataire->setPhone($request->request->getString('phone') ?: null);
|
||||
$prestataire->setAddress($request->request->getString('address') ?: null);
|
||||
$prestataire->setZipCode($request->request->getString('zipCode') ?: null);
|
||||
$prestataire->setCity($request->request->getString('city') ?: null);
|
||||
|
||||
$this->em->persist($prestataire);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Prestataire "'.$raisonSociale.'" cree.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
|
||||
public function show(Prestataire $prestataire): Response
|
||||
{
|
||||
return $this->render('admin/prestataires/show.html.twig', [
|
||||
'prestataire' => $prestataire,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/edit', name: 'edit', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function edit(Prestataire $prestataire, Request $request): Response
|
||||
{
|
||||
$raisonSociale = trim($request->request->getString('raisonSociale'));
|
||||
if ('' !== $raisonSociale) {
|
||||
$prestataire->setRaisonSociale($raisonSociale);
|
||||
}
|
||||
|
||||
$prestataire->setSiret($request->request->getString('siret') ?: null);
|
||||
$prestataire->setEmail($request->request->getString('email') ?: null);
|
||||
$prestataire->setPhone($request->request->getString('phone') ?: null);
|
||||
$prestataire->setAddress($request->request->getString('address') ?: null);
|
||||
$prestataire->setZipCode($request->request->getString('zipCode') ?: null);
|
||||
$prestataire->setCity($request->request->getString('city') ?: null);
|
||||
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'Prestataire mis a jour.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/delete', name: 'delete', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function delete(Prestataire $prestataire): Response
|
||||
{
|
||||
$name = $prestataire->getRaisonSociale();
|
||||
$this->em->remove($prestataire);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'Prestataire "'.$name.'" supprime.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy recherche entreprise via API data.gouv.fr.
|
||||
*/
|
||||
#[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])]
|
||||
public function entrepriseSearch(Request $request, HttpClientInterface $httpClient): JsonResponse
|
||||
{
|
||||
$query = trim($request->query->getString('q'));
|
||||
|
||||
if (\strlen($query) < 2) {
|
||||
return new JsonResponse(['results' => [], 'total_results' => 0]);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $httpClient->request('GET', 'https://recherche-entreprises.api.gouv.fr/search', [
|
||||
'query' => [
|
||||
'q' => $query,
|
||||
'page' => 1,
|
||||
'per_page' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
return new JsonResponse($response->toArray());
|
||||
} catch (\Throwable) {
|
||||
return new JsonResponse(['results' => [], 'total_results' => 0, 'error' => 'Service indisponible'], 502);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Factures prestataire
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[Route('/{id}/facture/add', name: 'facture_add', requirements: ['id' => '\d+'], methods: ['POST'])]
|
||||
public function addFacture(Prestataire $prestataire, Request $request): Response
|
||||
{
|
||||
$numFacture = trim($request->request->getString('numFacture'));
|
||||
$montantHt = $request->request->getString('montantHt');
|
||||
$montantTtc = $request->request->getString('montantTtc');
|
||||
$year = $request->request->getInt('year');
|
||||
$month = $request->request->getInt('month');
|
||||
|
||||
if ('' === $numFacture || $year < 2020 || $month < 1 || $month > 12) {
|
||||
$this->addFlash('error', 'Donnees invalides.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
|
||||
}
|
||||
|
||||
$facture = new FacturePrestataire($prestataire, $numFacture, $year, $month);
|
||||
$facture->setMontantHt(number_format((float) $montantHt, 2, '.', ''));
|
||||
$facture->setMontantTtc(number_format((float) $montantTtc, 2, '.', ''));
|
||||
|
||||
/** @var UploadedFile|null $file */
|
||||
$file = $request->files->get('facturePdf');
|
||||
if (null !== $file && $file->isValid()) {
|
||||
$facture->setFacturePdfFile($file);
|
||||
}
|
||||
|
||||
$prestataire->addFacture($facture);
|
||||
$this->em->persist($facture);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Facture '.$numFacture.' ajoutee pour '.$facture->getPeriodLabel().'.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/facture/{factureId}/paid', name: 'facture_paid', requirements: ['id' => '\d+', 'factureId' => '\d+'], methods: ['POST'])]
|
||||
public function markPaid(Prestataire $prestataire, int $factureId): Response
|
||||
{
|
||||
$facture = $this->em->getRepository(FacturePrestataire::class)->find($factureId);
|
||||
|
||||
if (null !== $facture && $facture->getPrestataire()->getId() === $prestataire->getId()) {
|
||||
$facture->setIsPaid(true);
|
||||
$facture->setPaidAt(new \DateTimeImmutable());
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'Facture '.$facture->getNumFacture().' marquee payee.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/{id}/facture/{factureId}/delete', name: 'facture_delete', requirements: ['id' => '\d+', 'factureId' => '\d+'], methods: ['POST'])]
|
||||
public function deleteFacture(Prestataire $prestataire, int $factureId): Response
|
||||
{
|
||||
$facture = $this->em->getRepository(FacturePrestataire::class)->find($factureId);
|
||||
|
||||
if (null !== $facture && $facture->getPrestataire()->getId() === $prestataire->getId()) {
|
||||
$this->em->remove($facture);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'Facture supprimee.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_prestataires_show', ['id' => $prestataire->getId()]);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class RevendeursController extends AbstractController
|
||||
|
||||
$mailer->sendEmail(
|
||||
$email,
|
||||
'CRM SITECONSEIL - Bienvenue dans l\'espace revendeur',
|
||||
'CRM E-Cosplay - Bienvenue dans l\'espace revendeur',
|
||||
$twig->render('emails/revendeur_created.html.twig', [
|
||||
'firstName' => $firstName,
|
||||
'lastName' => $lastName,
|
||||
@@ -180,7 +180,7 @@ class RevendeursController extends AbstractController
|
||||
Environment $twig,
|
||||
#[Autowire('%kernel.project_dir%')] string $projectDir,
|
||||
): Response {
|
||||
$logoPath = $projectDir.'/public/logo_facture.png';
|
||||
$logoPath = $projectDir.'/public/logo.jpg';
|
||||
$logo = file_exists($logoPath) ? 'data:image/jpeg;base64,'.base64_encode(file_get_contents($logoPath)) : '';
|
||||
|
||||
$html = $twig->render('pdf/contrat_revendeur.html.twig', [
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\AdvertPayment;
|
||||
use App\Entity\Facture;
|
||||
use App\Entity\FacturePrestataire;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -16,6 +21,11 @@ class StatsController extends AbstractController
|
||||
private const STATUS_RENTABLE = 'Rentable';
|
||||
private const STATUS_NEGATIF = 'Negatif';
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('', name: 'index')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
@@ -34,87 +44,307 @@ class StatsController extends AbstractController
|
||||
$dateTo = $now->format('Y-m-d');
|
||||
}
|
||||
|
||||
// Donnees fictives
|
||||
$rawServices = [
|
||||
['name' => 'Esy-Web', 'slug' => 'esy-web', 'color' => '#fabf04', 'ca_ht' => 4_200.00, 'cout_infra' => 80.00, 'cout_prestataire' => 70.00, 'clients' => 12, 'abonnements' => 12],
|
||||
['name' => 'Esy-Mail', 'slug' => 'esy-mail', 'color' => '#dc2626', 'ca_ht' => 850.00, 'cout_infra' => 45.00, 'cout_prestataire' => 25.00, 'clients' => 15, 'abonnements' => 42],
|
||||
['name' => 'Esy-Mailer', 'slug' => 'esy-mailer', 'color' => '#ea580c', 'ca_ht' => 1_100.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 4, 'abonnements' => 4],
|
||||
['name' => 'Esy-Analytics', 'slug' => 'esy-analytics', 'color' => '#4338ca', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
|
||||
['name' => 'Esy-Monitor', 'slug' => 'esy-monitor', 'color' => '#16a34a', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
|
||||
['name' => 'Esy-Defender', 'slug' => 'esy-defender', 'color' => '#0891b2', 'ca_ht' => 600.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 3, 'abonnements' => 3],
|
||||
['name' => 'Esy-Translate', 'slug' => 'esy-translate', 'color' => '#8b5cf6', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
|
||||
['name' => 'Esy-Signature', 'slug' => 'esy-signature', 'color' => '#7c3aed', 'ca_ht' => 400.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 2, 'abonnements' => 2],
|
||||
['name' => 'Esy-Creator', 'slug' => 'esy-creator', 'color' => '#d946ef', 'ca_ht' => 1_200.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 2, 'abonnements' => 2],
|
||||
['name' => 'Esy-Aide', 'slug' => 'esy-aide', 'color' => '#14b8a6', 'ca_ht' => 0.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 12, 'abonnements' => 12],
|
||||
['name' => 'Esy-Meet', 'slug' => 'esy-meet', 'color' => '#f97316', 'ca_ht' => 300.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 1, 'abonnements' => 1],
|
||||
['name' => 'Esy-Tchat', 'slug' => 'esy-tchat', 'color' => '#06b6d4', 'ca_ht' => 150.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 1, 'abonnements' => 1],
|
||||
['name' => 'Nom de domaine','slug' => 'ndd', 'color' => '#64748b', 'ca_ht' => 350.00, 'cout_infra' => 0.00, 'cout_prestataire' => 0.00, 'clients' => 8, 'abonnements' => 8],
|
||||
];
|
||||
$from = new \DateTimeImmutable($dateFrom);
|
||||
$to = new \DateTimeImmutable($dateTo.' 23:59:59');
|
||||
|
||||
$servicesStats = [];
|
||||
$totalCoutInfra = 0.0;
|
||||
$totalCoutPrestataire = 0.0;
|
||||
$totalCaHt = 0.0;
|
||||
// CA dynamique depuis les factures payees
|
||||
$factureStats = $this->getFactureStats($from, $to);
|
||||
|
||||
foreach ($rawServices as $s) {
|
||||
$caTva = $s['ca_ht'] * 0.2;
|
||||
$coutTotal = $s['cout_infra'] + $s['cout_prestataire'];
|
||||
$margeNette = $s['ca_ht'] - $coutTotal;
|
||||
// CA dynamique depuis les paiements Stripe (AdvertPayment)
|
||||
$paymentStats = $this->getPaymentStats($from, $to);
|
||||
|
||||
$totalCoutInfra += $s['cout_infra'];
|
||||
$totalCoutPrestataire += $s['cout_prestataire'];
|
||||
$totalCaHt += $s['ca_ht'];
|
||||
// Stats globales
|
||||
$totalCaHt = (float) $factureStats['totalHt'];
|
||||
$totalCaTva = (float) $factureStats['totalTva'];
|
||||
$totalCaTtc = (float) $factureStats['totalTtc'];
|
||||
$commissionStripe = $totalCaTtc * 0.015;
|
||||
|
||||
$servicesStats[] = array_merge($s, [
|
||||
'ca_tva' => $caTva,
|
||||
'ca_ttc' => $s['ca_ht'] + $caTva,
|
||||
'cout_total' => $coutTotal,
|
||||
'marge_nette' => $margeNette,
|
||||
'status' => $this->resolveStatus($margeNette, $s['ca_ht']),
|
||||
]);
|
||||
}
|
||||
|
||||
$totalCoutGlobal = $totalCoutInfra + $totalCoutPrestataire;
|
||||
$commissionStripe = $totalCaHt * 0.015;
|
||||
$margeNetteGlobale = $totalCaHt - $totalCoutGlobal - $commissionStripe;
|
||||
// Cout prestataire dynamique depuis les factures prestataires de la periode
|
||||
$coutPrestataire = $this->getCoutPrestataire($from, $to);
|
||||
$coutInfra = 80.0;
|
||||
$coutFonctionnement = $coutInfra + $coutPrestataire;
|
||||
$coutTotal = $coutFonctionnement + $commissionStripe;
|
||||
|
||||
$globalStats = [
|
||||
'ca_ht' => $totalCaHt,
|
||||
'ca_tva' => $totalCaHt * 0.2,
|
||||
'ca_ttc' => $totalCaHt * 1.2,
|
||||
'cout_infra' => $totalCoutInfra,
|
||||
'cout_prestataire' => $totalCoutPrestataire,
|
||||
'cout_total' => $totalCoutGlobal,
|
||||
'ca_tva' => $totalCaTva,
|
||||
'ca_ttc' => $totalCaTtc,
|
||||
'commission_stripe' => $commissionStripe,
|
||||
'marge_nette' => $margeNetteGlobale,
|
||||
'status' => $this->resolveStatus($margeNetteGlobale, $totalCaHt),
|
||||
'factures_emises' => $totalCaHt * 1.2,
|
||||
'factures_payees' => $totalCaHt * 1.2 * 0.85,
|
||||
'factures_impayees' => $totalCaHt * 1.2 * 0.15,
|
||||
'nb_factures_emises' => 28,
|
||||
'nb_factures_payees' => 24,
|
||||
'nb_factures_impayees' => 4,
|
||||
'cout_infra' => $coutInfra,
|
||||
'cout_prestataire' => $coutPrestataire,
|
||||
'cout_fonctionnement' => $coutFonctionnement,
|
||||
'marge_nette' => $totalCaHt - $coutTotal,
|
||||
'status' => $this->resolveStatus($totalCaHt - $coutTotal, $totalCaHt),
|
||||
'factures_emises' => $factureStats['nbTotal'],
|
||||
'factures_payees' => $factureStats['nbPaid'],
|
||||
'factures_impayees' => $factureStats['nbTotal'] - $factureStats['nbPaid'],
|
||||
'montant_emis' => $factureStats['totalEmis'],
|
||||
'montant_paye' => $factureStats['totalTtc'],
|
||||
'montant_impaye' => $factureStats['totalEmis'] - $factureStats['totalTtc'],
|
||||
];
|
||||
|
||||
$monthlyEvolution = [
|
||||
['month' => 'Oct 2025', 'ca_ht' => 9_800],
|
||||
['month' => 'Nov 2025', 'ca_ht' => 10_200],
|
||||
['month' => 'Dec 2025', 'ca_ht' => 11_500],
|
||||
['month' => 'Jan 2026', 'ca_ht' => 10_900],
|
||||
['month' => 'Fev 2026', 'ca_ht' => 11_800],
|
||||
['month' => 'Mar 2026', 'ca_ht' => $totalCaHt],
|
||||
];
|
||||
// Paiements par methode
|
||||
$paymentsByMethod = $this->getPaymentsByMethod($from, $to);
|
||||
|
||||
// Evolution mensuelle (6 derniers mois)
|
||||
$monthlyEvolution = $this->getMonthlyEvolution();
|
||||
|
||||
// Services dynamiques depuis les lignes de factures payees
|
||||
$services = $this->getServiceStats($from, $to);
|
||||
|
||||
return $this->render('admin/stats/index.html.twig', [
|
||||
'period' => $period,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'global' => $globalStats,
|
||||
'services' => $servicesStats,
|
||||
'services' => $services,
|
||||
'evolution' => $monthlyEvolution,
|
||||
'payments' => $paymentStats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{totalHt: string, totalTva: string, totalTtc: string, totalEmis: string, nbTotal: int, nbPaid: int}
|
||||
*/
|
||||
private function getFactureStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
$qb = $this->em->createQueryBuilder();
|
||||
|
||||
// Toutes les factures de la periode
|
||||
$allFactures = $qb->select('f')
|
||||
->from(Facture::class, 'f')
|
||||
->where('f.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$totalHt = 0.0;
|
||||
$totalTva = 0.0;
|
||||
$totalTtc = 0.0;
|
||||
$totalEmis = 0.0;
|
||||
$nbPaid = 0;
|
||||
|
||||
foreach ($allFactures as $facture) {
|
||||
$totalEmis += (float) $facture->getTotalTtc();
|
||||
|
||||
if ($facture->isPaid()) {
|
||||
$totalHt += (float) $facture->getTotalHt();
|
||||
$totalTva += (float) $facture->getTotalTva();
|
||||
$totalTtc += (float) $facture->getTotalTtc();
|
||||
++$nbPaid;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'totalHt' => number_format($totalHt, 2, '.', ''),
|
||||
'totalTva' => number_format($totalTva, 2, '.', ''),
|
||||
'totalTtc' => number_format($totalTtc, 2, '.', ''),
|
||||
'totalEmis' => $totalEmis,
|
||||
'nbTotal' => \count($allFactures),
|
||||
'nbPaid' => $nbPaid,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: string, count: int}
|
||||
*/
|
||||
private function getPaymentStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
$payments = $this->em->createQueryBuilder()
|
||||
->select('p')
|
||||
->from(AdvertPayment::class, 'p')
|
||||
->where('p.createdAt BETWEEN :from AND :to')
|
||||
->andWhere('p.type = :type')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->setParameter('type', AdvertPayment::TYPE_SUCCESS)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($payments as $p) {
|
||||
$total += (float) $p->getAmount();
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => number_format($total, 2, '.', ''),
|
||||
'count' => \count($payments),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{method: string, total: float, count: int}>
|
||||
*/
|
||||
private function getPaymentsByMethod(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
$payments = $this->em->createQueryBuilder()
|
||||
->select('p')
|
||||
->from(AdvertPayment::class, 'p')
|
||||
->where('p.createdAt BETWEEN :from AND :to')
|
||||
->andWhere('p.type = :type')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->setParameter('type', AdvertPayment::TYPE_SUCCESS)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($payments as $p) {
|
||||
$method = $p->getMethod() ?? 'inconnu';
|
||||
if (!isset($grouped[$method])) {
|
||||
$grouped[$method] = ['method' => $method, 'total' => 0.0, 'count' => 0];
|
||||
}
|
||||
$grouped[$method]['total'] += (float) $p->getAmount();
|
||||
++$grouped[$method]['count'];
|
||||
}
|
||||
|
||||
usort($grouped, fn ($a, $b) => $b['total'] <=> $a['total']);
|
||||
|
||||
return array_values($grouped);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{month: string, ca_ht: float, ca_ttc: float}>
|
||||
*/
|
||||
private function getMonthlyEvolution(): array
|
||||
{
|
||||
$result = [];
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
for ($i = 5; $i >= 0; --$i) {
|
||||
$monthStart = $now->modify('-'.$i.' months')->modify('first day of this month');
|
||||
$monthEnd = $monthStart->modify('last day of this month 23:59:59');
|
||||
|
||||
$factures = $this->em->createQueryBuilder()
|
||||
->select('f')
|
||||
->from(Facture::class, 'f')
|
||||
->where('f.createdAt BETWEEN :from AND :to')
|
||||
->andWhere('f.isPaid = true')
|
||||
->setParameter('from', $monthStart)
|
||||
->setParameter('to', $monthEnd)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$caHt = 0.0;
|
||||
$caTtc = 0.0;
|
||||
foreach ($factures as $f) {
|
||||
$caHt += (float) $f->getTotalHt();
|
||||
$caTtc += (float) $f->getTotalTtc();
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'month' => $monthStart->format('M Y'),
|
||||
'ca_ht' => $caHt,
|
||||
'ca_ttc' => $caTtc,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private const SERVICE_CONFIG = [
|
||||
'website' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 150.00, 'group' => 'esite'],
|
||||
'hosting' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 0.00, 'group' => 'esite'],
|
||||
'maintenance' => ['name' => 'E-Site', 'color' => '#fabf04', 'cout' => 0.00, 'group' => 'esite'],
|
||||
'esymail' => ['name' => 'E-Mail', 'color' => '#dc2626', 'cout' => 70.00, 'group' => 'esymail'],
|
||||
'ndd' => ['name' => 'Nom de domaine', 'color' => '#64748b', 'cout' => 0.00, 'cout_par_ligne' => 15.00, 'group' => 'ndd'],
|
||||
'other' => ['name' => 'Autre', 'color' => '#6b7280', 'cout' => 0.00, 'group' => 'other'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return list<array{name: string, color: string, ca_ht: float, cout: float, marge: float, status: string, clients: int}>
|
||||
*/
|
||||
private function getServiceStats(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
// Recuperer toutes les lignes de factures payees sur la periode
|
||||
$factures = $this->em->createQueryBuilder()
|
||||
->select('f')
|
||||
->from(Facture::class, 'f')
|
||||
->where('f.createdAt BETWEEN :from AND :to')
|
||||
->andWhere('f.isPaid = true')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
// Grouper les lignes par type
|
||||
$raw = [];
|
||||
foreach (self::SERVICE_CONFIG as $type => $config) {
|
||||
$raw[$type] = ['ca_ht' => 0.0, 'customers' => [], 'lines' => 0];
|
||||
}
|
||||
|
||||
foreach ($factures as $facture) {
|
||||
$customerId = $facture->getCustomer()?->getId();
|
||||
foreach ($facture->getLines() as $line) {
|
||||
$type = $line->getType() ?? 'other';
|
||||
if (!isset($raw[$type])) {
|
||||
$type = 'other';
|
||||
}
|
||||
$raw[$type]['ca_ht'] += (float) $line->getPriceHt();
|
||||
// Cout par ligne NDD : uniquement Renouvellement et Depot (pas Gestion)
|
||||
$title = $line->getTitle();
|
||||
if ('ndd' !== $type || str_contains($title, 'Renouvellement') || str_contains($title, 'Depot')) {
|
||||
++$raw[$type]['lines'];
|
||||
}
|
||||
if (null !== $customerId) {
|
||||
$raw[$type]['customers'][$customerId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fusionner par group (website+hosting+maintenance -> esite)
|
||||
$grouped = [];
|
||||
foreach (self::SERVICE_CONFIG as $type => $config) {
|
||||
$group = $config['group'];
|
||||
if (!isset($grouped[$group])) {
|
||||
$grouped[$group] = ['name' => $config['name'], 'color' => $config['color'], 'ca_ht' => 0.0, 'cout' => 0.0, 'customers' => []];
|
||||
}
|
||||
$grouped[$group]['ca_ht'] += $raw[$type]['ca_ht'];
|
||||
$grouped[$group]['cout'] += $config['cout'] + (($config['cout_par_ligne'] ?? 0.0) * $raw[$type]['lines']);
|
||||
$grouped[$group]['customers'] += $raw[$type]['customers'];
|
||||
}
|
||||
|
||||
$services = [];
|
||||
foreach ($grouped as $data) {
|
||||
$marge = $data['ca_ht'] - $data['cout'];
|
||||
|
||||
$services[] = [
|
||||
'name' => $data['name'],
|
||||
'color' => $data['color'],
|
||||
'ca_ht' => $data['ca_ht'],
|
||||
'cout' => $data['cout'],
|
||||
'marge' => $marge,
|
||||
'status' => $data['ca_ht'] <= 0 ? 'Inactif' : ($marge >= 0 ? 'Rentable' : 'Negatif'),
|
||||
'clients' => \count($data['customers']),
|
||||
];
|
||||
}
|
||||
|
||||
// Trier par CA decroissant
|
||||
usort($services, fn ($a, $b) => $b['ca_ht'] <=> $a['ca_ht']);
|
||||
|
||||
return $services;
|
||||
}
|
||||
|
||||
private function getCoutPrestataire(\DateTimeImmutable $from, \DateTimeImmutable $to): float
|
||||
{
|
||||
$factures = $this->em->createQueryBuilder()
|
||||
->select('fp')
|
||||
->from(FacturePrestataire::class, 'fp')
|
||||
->where('fp.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($factures as $fp) {
|
||||
$total += (float) $fp->getMontantHt();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function resolveStatus(float $margeNette, float $caHt): string
|
||||
{
|
||||
if ($caHt <= 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Entity\Advert;
|
||||
use App\Entity\CustomerContact;
|
||||
use App\Entity\Devis;
|
||||
use App\Entity\Domain;
|
||||
use App\Entity\Facture;
|
||||
use App\Entity\StripeWebhookSecret;
|
||||
use App\Entity\Website;
|
||||
use App\Repository\CustomerRepository;
|
||||
@@ -28,7 +29,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class SyncController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'index')]
|
||||
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em): Response
|
||||
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
$prices = $priceRepository->findAll();
|
||||
$stripeSynced = 0;
|
||||
@@ -68,10 +69,29 @@ class SyncController extends AbstractController
|
||||
'totalWebsites' => $em->getRepository(Website::class)->count([]),
|
||||
'totalDevis' => $em->getRepository(Devis::class)->count([]),
|
||||
'totalAdverts' => $em->getRepository(Advert::class)->count([]),
|
||||
'totalFactures' => $em->getRepository(Facture::class)->count([]),
|
||||
'webhookSecrets' => $webhookSecrets,
|
||||
'msCustomers' => $meilisearch->getIndexCount('customer'),
|
||||
'msResellers' => $meilisearch->getIndexCount('reseller'),
|
||||
'msPrices' => $meilisearch->getIndexCount('price_auto'),
|
||||
'msContacts' => $meilisearch->getIndexCount('customer_contact'),
|
||||
'msDomains' => $meilisearch->getIndexCount('customer_ndd'),
|
||||
'msWebsites' => $meilisearch->getIndexCount('customer_website'),
|
||||
'msDevis' => $meilisearch->getIndexCount('customer_devis'),
|
||||
'msAdverts' => $meilisearch->getIndexCount('customer_advert'),
|
||||
'msFactures' => $meilisearch->getIndexCount('customer_facture'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/purge-indexes', name: 'purge_indexes', methods: ['POST'])]
|
||||
public function purgeIndexes(MeilisearchService $meilisearch): Response
|
||||
{
|
||||
$meilisearch->purgeAllIndexes();
|
||||
$this->addFlash('success', 'Tous les index Meilisearch ont ete purges.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
#[Route('/customers', name: 'customers', methods: ['POST'])]
|
||||
public function syncCustomers(CustomerRepository $customerRepository, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
@@ -174,6 +194,23 @@ class SyncController extends AbstractController
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
#[Route('/factures', name: 'factures', methods: ['POST'])]
|
||||
public function syncFactures(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
try {
|
||||
$meilisearch->setupIndexes();
|
||||
$items = $em->getRepository(Facture::class)->findAll();
|
||||
foreach ($items as $item) {
|
||||
$meilisearch->indexFacture($item);
|
||||
}
|
||||
$this->addFlash('success', \count($items).' facture(s) synchronisee(s) dans Meilisearch.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->addFlash('error', 'Erreur sync factures : '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_sync_index');
|
||||
}
|
||||
|
||||
#[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])]
|
||||
public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response
|
||||
{
|
||||
|
||||
@@ -7,7 +7,9 @@ use App\Service\MeilisearchService;
|
||||
use App\Service\StripePriceService;
|
||||
use App\Service\TarificationService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -75,4 +77,40 @@ class TarificationController extends AbstractController
|
||||
|
||||
return $this->redirectToRoute('app_admin_tarification');
|
||||
}
|
||||
|
||||
#[Route('/purge', name: '_purge', methods: ['POST'])]
|
||||
public function purge(
|
||||
PriceAutomaticRepository $repository,
|
||||
EntityManagerInterface $em,
|
||||
MeilisearchService $meilisearch,
|
||||
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||
): Response {
|
||||
$prices = $repository->findAll();
|
||||
$count = \count($prices);
|
||||
|
||||
foreach ($prices as $price) {
|
||||
// Supprimer le produit Stripe si existe
|
||||
if ('' !== $stripeSk && null !== $price->getStripeId()) {
|
||||
try {
|
||||
\Stripe\Stripe::setApiKey($stripeSk);
|
||||
\Stripe\Product::update($price->getStripeId(), ['active' => false]);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer de Meilisearch
|
||||
try {
|
||||
$meilisearch->removePrice($price->getId());
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
$em->remove($price);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', $count.' tarif(s) purge(s) (DB + Stripe desactives + Meilisearch).');
|
||||
|
||||
return $this->redirectToRoute('app_admin_tarification');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class CspReportController extends AbstractController
|
||||
private function sendAlert(MailerInterface $mailer, LoggerInterface $logger, array $report, string $documentUri, string $violatedDirective, string $blockedUri, string $sourceFile): void
|
||||
{
|
||||
$email = (new Email())
|
||||
->from('security-notify@siteconseil.fr')
|
||||
->from('security-notify@e-cosplay.fr')
|
||||
->to($this->getParameter('admin_email'))
|
||||
->subject('Alerte Securite : Violation CSP detectee')
|
||||
->priority(Email::PRIORITY_HIGH)
|
||||
|
||||
@@ -94,6 +94,28 @@ class DevisProcessController extends AbstractController
|
||||
if ('' !== $reason) {
|
||||
$devis->setRaisonMessage($reason);
|
||||
}
|
||||
|
||||
// Libere le numero de commande
|
||||
$devis->getOrderNumber()->markAsUnused();
|
||||
|
||||
// Annule la submission dans DocuSeal
|
||||
$submitterId = (int) ($devis->getSubmissionId() ?? '0');
|
||||
if ($submitterId > 0) {
|
||||
try {
|
||||
$submitter = $this->docuSeal->getSubmitterSlug($submitterId);
|
||||
// Recupere le submission_id via l'API pour archiver
|
||||
$submitterData = $this->docuSeal->getSubmitterData($submitterId);
|
||||
if (null !== $submitterData) {
|
||||
$submissionId = $submitterData['submission_id'] ?? null;
|
||||
if (null !== $submissionId) {
|
||||
$this->docuSeal->archiveSubmission((int) $submissionId);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Silencieux : le refus est enregistre meme si DocuSeal echoue
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->render('devis/refused.html.twig', [
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class EmailTrackingController extends AbstractController
|
||||
{
|
||||
#[Route('/track/{messageId}/logo_facture.png', name: 'app_email_track', methods: ['GET'])]
|
||||
#[Route('/track/{messageId}/logo.jpg', name: 'app_email_track', methods: ['GET'])]
|
||||
public function track(
|
||||
string $messageId,
|
||||
EmailTrackingRepository $repository,
|
||||
@@ -27,7 +27,7 @@ class EmailTrackingController extends AbstractController
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($projectDir.'/public/logo_facture.png');
|
||||
$response = new BinaryFileResponse($projectDir.'/public/logo.jpg');
|
||||
$response->headers->set('Content-Type', 'image/jpeg');
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
|
||||
|
||||
27
src/Controller/FactureVerifyController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Facture;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class FactureVerifyController extends AbstractController
|
||||
{
|
||||
#[Route('/facture/verify/{id}/{hmac}', name: 'app_facture_verify', requirements: ['id' => '\d+'])]
|
||||
public function index(int $id, string $hmac, EntityManagerInterface $em): Response
|
||||
{
|
||||
$facture = $em->getRepository(Facture::class)->find($id);
|
||||
|
||||
if (null === $facture || !hash_equals($facture->getHmac(), $hmac)) {
|
||||
return $this->render('facture/verify_invalid.html.twig');
|
||||
}
|
||||
|
||||
return $this->render('facture/verify.html.twig', [
|
||||
'facture' => $facture,
|
||||
'customer' => $facture->getCustomer(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ class ForgotPasswordController extends AbstractController
|
||||
if (null !== $user) {
|
||||
$mailer->sendEmail(
|
||||
$email,
|
||||
'CRM SITECONSEIL - Code de reinitialisation',
|
||||
'CRM E-Cosplay - Code de reinitialisation',
|
||||
$twig->render('emails/forgot_password_code.html.twig', ['code' => $code]),
|
||||
null,
|
||||
null,
|
||||
@@ -106,7 +106,7 @@ class ForgotPasswordController extends AbstractController
|
||||
|
||||
$mailer->sendEmail(
|
||||
$sessionEmail,
|
||||
'CRM SITECONSEIL - Mot de passe modifie',
|
||||
'CRM E-Cosplay - Mot de passe modifie',
|
||||
$twig->render('emails/password_changed.html.twig'),
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -113,19 +113,11 @@ class LegalController extends AbstractController
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $rgpd->handleAccessRequest($ip, $email);
|
||||
// Envoie un code de verification par email avant de generer l'attestation
|
||||
$rgpd->sendVerificationCode($email, $ip, 'access');
|
||||
$this->addFlash('success', 'Un code de verification a ete envoye a '.$email.'. Veuillez le saisir ci-dessous pour valider votre demande.');
|
||||
|
||||
if ($result['found']) {
|
||||
$this->addFlash('success', 'Vos donnees ont ete envoyees par email.');
|
||||
} else {
|
||||
$this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP. Un email de confirmation a ete envoye.');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@siteconseil.fr.');
|
||||
}
|
||||
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd_verify', ['type' => 'access', 'email' => $email, 'ip' => $ip]));
|
||||
}
|
||||
|
||||
#[Route('/rgpd/suppression', name: 'rgpd_deletion', methods: ['POST'])]
|
||||
@@ -140,16 +132,64 @@ class LegalController extends AbstractController
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $rgpd->handleDeletionRequest($ip, $email);
|
||||
// Verification par code email
|
||||
$rgpd->sendVerificationCode($email, $ip, 'deletion');
|
||||
$this->addFlash('success', 'Un code de verification a ete envoye a '.$email.'. Veuillez le saisir ci-dessous pour valider votre demande.');
|
||||
|
||||
if ($result['found']) {
|
||||
$this->addFlash('success', 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.');
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd_verify', ['type' => 'deletion', 'email' => $email, 'ip' => $ip]));
|
||||
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
|
||||
}
|
||||
|
||||
#[Route('/rgpd/verify', name: 'rgpd_verify', methods: ['GET', 'POST'])]
|
||||
public function rgpdVerify(Request $request, RgpdService $rgpd): Response
|
||||
{
|
||||
$type = $request->query->getString('type', $request->request->getString('type'));
|
||||
$email = $request->query->getString('email', $request->request->getString('email'));
|
||||
$ip = $request->query->getString('ip', $request->request->getString('ip'));
|
||||
|
||||
if ('' === $type || '' === $email || '' === $ip) {
|
||||
$this->addFlash('error', 'Parametres manquants.');
|
||||
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
|
||||
}
|
||||
|
||||
// GET : affiche le formulaire de saisie du code
|
||||
if ('GET' === $request->getMethod()) {
|
||||
return $this->render('legal/rgpd_verify.html.twig', [
|
||||
'type' => $type,
|
||||
'email' => $email,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
// POST : verifie le code
|
||||
$code = trim($request->request->getString('code'));
|
||||
if (!$rgpd->verifyCode($email, $ip, $type, $code)) {
|
||||
$this->addFlash('error', 'Code invalide ou expire. Veuillez reessayer.');
|
||||
|
||||
return $this->render('legal/rgpd_verify.html.twig', [
|
||||
'type' => $type,
|
||||
'email' => $email,
|
||||
'ip' => $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
// Code valide : executer la demande
|
||||
try {
|
||||
if ('access' === $type) {
|
||||
$result = $rgpd->handleAccessRequest($ip, $email);
|
||||
$this->addFlash('success', $result['found']
|
||||
? 'Votre attestation a ete generee et signee. Elle sera envoyee sur votre boite mail '.$email.'.'
|
||||
: 'Aucune donnee trouvee pour cette adresse IP. Une attestation d\'absence de donnees sera envoyee sur votre boite mail '.$email.'.');
|
||||
} else {
|
||||
$this->addFlash('success', 'Aucune donnee trouvee pour cette adresse IP.');
|
||||
$result = $rgpd->handleDeletionRequest($ip, $email);
|
||||
$this->addFlash('success', $result['found']
|
||||
? 'Vos donnees ont ete supprimees. Une attestation a ete envoyee par email.'
|
||||
: 'Aucune donnee trouvee pour cette adresse IP.');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@siteconseil.fr.');
|
||||
$this->addFlash('error', 'Une erreur est survenue lors du traitement de votre demande. Veuillez reessayer ou nous contacter a contact@e-cosplay.fr.');
|
||||
}
|
||||
|
||||
return $this->redirect($this->generateUrl('app_legal_rgpd').self::RGPD_ANCHOR);
|
||||
|
||||
@@ -3,22 +3,434 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\AdvertEvent;
|
||||
use App\Entity\Revendeur;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
class OrderPaymentController extends AbstractController
|
||||
{
|
||||
private const NOTIFICATION_EMAIL = 'notification@e-cosplay.fr';
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private MailerService $mailer,
|
||||
private Environment $twig,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/order/{numOrder}', name: 'app_order_payment', requirements: ['numOrder' => '.+'])]
|
||||
public function index(string $numOrder, EntityManagerInterface $em): Response
|
||||
public function index(
|
||||
string $numOrder,
|
||||
\Symfony\Component\HttpFoundation\Request $request,
|
||||
#[Autowire(env: 'STRIPE_PK')] string $stripePk = '',
|
||||
): Response {
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
$customer = $advert->getCustomer();
|
||||
$session = $request->getSession();
|
||||
$sessionKey = 'order_verified_'.$advert->getId();
|
||||
|
||||
// Verifier si deja authentifie pour cet avis
|
||||
if (!$session->get($sessionKey, false)) {
|
||||
return $this->redirectToRoute('app_order_verify', ['numOrder' => $numOrder]);
|
||||
}
|
||||
|
||||
// Tracker la visite
|
||||
$event = new AdvertEvent($advert, AdvertEvent::TYPE_VIEW, 'Page paiement consultee', $request->getClientIp());
|
||||
$this->em->persist($event);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->render('order/payment.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $customer,
|
||||
'stripe_pk' => $stripePk,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/order-verify/{numOrder}', name: 'app_order_verify', requirements: ['numOrder' => '.+'])]
|
||||
public function verify(
|
||||
string $numOrder,
|
||||
\Symfony\Component\HttpFoundation\Request $request,
|
||||
): Response {
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
$customer = $advert->getCustomer();
|
||||
$session = $request->getSession();
|
||||
$sessionKey = 'order_verified_'.$advert->getId();
|
||||
|
||||
// Deja verifie
|
||||
if ($session->get($sessionKey, false)) {
|
||||
return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]);
|
||||
}
|
||||
|
||||
if (null === $customer || null === $customer->getEmail()) {
|
||||
// Pas de client/email : acces direct (fallback)
|
||||
$session->set($sessionKey, true);
|
||||
|
||||
return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]);
|
||||
}
|
||||
|
||||
$codeKey = 'order_code_'.$advert->getId();
|
||||
$codeExpiresKey = 'order_code_expires_'.$advert->getId();
|
||||
$error = null;
|
||||
|
||||
if ('POST' === $request->getMethod()) {
|
||||
$code = trim($request->request->getString('code'));
|
||||
$storedCode = $session->get($codeKey);
|
||||
$expiresAt = $session->get($codeExpiresKey, 0);
|
||||
|
||||
if (time() > $expiresAt) {
|
||||
$error = 'Code expire. Cliquez sur "Renvoyer le code" pour en recevoir un nouveau.';
|
||||
$session->remove($codeKey);
|
||||
$session->remove($codeExpiresKey);
|
||||
} elseif ($code === $storedCode) {
|
||||
// Code valide
|
||||
$session->set($sessionKey, true);
|
||||
$session->remove($codeKey);
|
||||
$session->remove($codeExpiresKey);
|
||||
|
||||
return $this->redirectToRoute('app_order_payment', ['numOrder' => $numOrder]);
|
||||
} else {
|
||||
$error = 'Code incorrect. Veuillez reessayer.';
|
||||
}
|
||||
}
|
||||
|
||||
// Envoyer le code si pas encore envoye ou expire
|
||||
if (null === $session->get($codeKey) || time() > $session->get($codeExpiresKey, 0)) {
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
|
||||
$session->set($codeKey, $code);
|
||||
$session->set($codeExpiresKey, time() + 900); // 15 minutes
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Code de verification - Avis '.$advert->getOrderNumber()->getNumOrder(),
|
||||
$this->twig->render('emails/order_verify_code.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'code' => $code,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->render('order/verify.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $customer,
|
||||
'error' => $error,
|
||||
'numOrder' => $numOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/order-verify/{numOrder}/resend', name: 'app_order_verify_resend', requirements: ['numOrder' => '.+'], methods: ['POST'])]
|
||||
public function verifyResend(
|
||||
string $numOrder,
|
||||
\Symfony\Component\HttpFoundation\Request $request,
|
||||
): Response {
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
$customer = $advert->getCustomer();
|
||||
$session = $request->getSession();
|
||||
|
||||
if (null !== $customer && null !== $customer->getEmail()) {
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
|
||||
$session->set('order_code_'.$advert->getId(), $code);
|
||||
$session->set('order_code_expires_'.$advert->getId(), time() + 900);
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Nouveau code de verification - Avis '.$advert->getOrderNumber()->getNumOrder(),
|
||||
$this->twig->render('emails/order_verify_code.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'code' => $code,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Un nouveau code a ete envoye.');
|
||||
|
||||
return $this->redirectToRoute('app_order_verify', ['numOrder' => $numOrder]);
|
||||
}
|
||||
|
||||
#[Route('/order-choose/virement/{numOrder}', name: 'app_order_payment_virement', requirements: ['numOrder' => '.+'], methods: ['POST'])]
|
||||
public function chooseVirement(string $numOrder): Response
|
||||
{
|
||||
$advert = $em->createQueryBuilder()
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
$customer = $advert->getCustomer();
|
||||
$num = $advert->getOrderNumber()->getNumOrder();
|
||||
|
||||
// Mail client : rappel coordonnees bancaires
|
||||
if (null !== $customer && null !== $customer->getEmail()) {
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Virement bancaire - Avis de paiement '.$num,
|
||||
$this->twig->render('emails/payment_virement_client.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Notification interne
|
||||
$this->mailer->sendEmail(
|
||||
self::NOTIFICATION_EMAIL,
|
||||
'Paiement par virement - '.$num.' - '.($customer?->getFullName() ?? 'Inconnu'),
|
||||
$this->twig->render('emails/payment_notification_admin.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'method' => 'Virement bancaire',
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
|
||||
return $this->render('order/payment_confirmed.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $customer,
|
||||
'method' => 'virement',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/order-choose/cheque/{numOrder}', name: 'app_order_payment_cheque', requirements: ['numOrder' => '.+'], methods: ['POST'])]
|
||||
public function chooseCheque(string $numOrder): Response
|
||||
{
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
$customer = $advert->getCustomer();
|
||||
$num = $advert->getOrderNumber()->getNumOrder();
|
||||
|
||||
// Mail client : rappel envoi cheque
|
||||
if (null !== $customer && null !== $customer->getEmail()) {
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Paiement par cheque - Avis de paiement '.$num,
|
||||
$this->twig->render('emails/payment_cheque_client.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Notification interne
|
||||
$this->mailer->sendEmail(
|
||||
self::NOTIFICATION_EMAIL,
|
||||
'Paiement par cheque - '.$num.' - '.($customer?->getFullName() ?? 'Inconnu'),
|
||||
$this->twig->render('emails/payment_notification_admin.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'method' => 'Cheque',
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
|
||||
return $this->render('order/payment_confirmed.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $customer,
|
||||
'method' => 'cheque',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/order-choose/stripe/{numOrder}', name: 'app_order_payment_stripe_intent', requirements: ['numOrder' => '.+'], methods: ['POST'])]
|
||||
public function createStripeIntent(
|
||||
string $numOrder,
|
||||
\Symfony\Component\HttpFoundation\Request $request,
|
||||
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||
): JsonResponse {
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
$customer = $advert->getCustomer();
|
||||
|
||||
if ('' === $stripeSk) {
|
||||
return $this->json(['error' => 'Stripe non configure'], 500);
|
||||
}
|
||||
|
||||
$amount = (int) round(((float) $advert->getTotalTtc()) * 100);
|
||||
if ($amount <= 0) {
|
||||
return $this->json(['error' => 'Montant invalide'], 400);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$paymentMethod = $body['method'] ?? 'card';
|
||||
|
||||
try {
|
||||
\Stripe\Stripe::setApiKey($stripeSk);
|
||||
|
||||
$params = [
|
||||
'amount' => $amount,
|
||||
'currency' => 'eur',
|
||||
'metadata' => [
|
||||
'advert_id' => $advert->getId(),
|
||||
'num_order' => $advert->getOrderNumber()->getNumOrder(),
|
||||
'customer_name' => $customer?->getFullName() ?? 'Inconnu',
|
||||
'customer_email' => $customer?->getEmail() ?? '',
|
||||
'payment_method' => $paymentMethod,
|
||||
],
|
||||
'description' => 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder(),
|
||||
];
|
||||
|
||||
if ('sepa' === $paymentMethod) {
|
||||
// Prelevement bancaire : SEPA + virement bancaire Stripe
|
||||
$params['payment_method_types'] = ['sepa_debit', 'customer_balance'];
|
||||
} else {
|
||||
// Paiement en ligne : toutes les methodes sauf SEPA/virement (bouton dedie)
|
||||
$params['automatic_payment_methods'] = ['enabled' => true];
|
||||
$params['excluded_payment_method_types'] = ['sepa_debit', 'customer_balance', 'bancontact', 'eps'];
|
||||
}
|
||||
|
||||
if (null !== $customer?->getStripeCustomerId()) {
|
||||
$params['customer'] = $customer->getStripeCustomerId();
|
||||
}
|
||||
|
||||
if (null !== $customer?->getEmail()) {
|
||||
$params['receipt_email'] = $customer->getEmail();
|
||||
}
|
||||
|
||||
// Stripe Connect : si le client a un revendeur avec Stripe Connect actif,
|
||||
// le paiement est lie au compte connecte (transfert automatique)
|
||||
$revendeur = $this->findRevendeur($customer);
|
||||
if (null !== $revendeur) {
|
||||
// > 1000€ : 75% E-Cosplay / 25% revendeur, sinon 50/50
|
||||
$feeRate = $amount > 100000 ? 0.75 : 0.50;
|
||||
$applicationFee = (int) round($amount * $feeRate);
|
||||
$params['application_fee_amount'] = $applicationFee;
|
||||
$params['transfer_data'] = [
|
||||
'destination' => $revendeur->getStripeConnectId(),
|
||||
];
|
||||
$params['metadata']['revendeur_code'] = $revendeur->getCodeRevendeur();
|
||||
$params['metadata']['revendeur_connect_id'] = $revendeur->getStripeConnectId();
|
||||
$params['metadata']['application_fee'] = (string) $applicationFee;
|
||||
}
|
||||
|
||||
$intent = \Stripe\PaymentIntent::create($params);
|
||||
|
||||
$advert->setStripePaymentId($intent->id);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json([
|
||||
'clientSecret' => $intent->client_secret,
|
||||
'method' => $paymentMethod,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/order-choose/stripe/{numOrder}/success', name: 'app_order_payment_stripe_success', requirements: ['numOrder' => '.+'], methods: ['GET'])]
|
||||
public function stripeSuccess(
|
||||
string $numOrder,
|
||||
\Symfony\Component\HttpFoundation\Request $request,
|
||||
#[Autowire(env: 'STRIPE_SK')] string $stripeSk = '',
|
||||
): Response {
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
|
||||
// Retirer la session de verification apres paiement
|
||||
$request->getSession()->remove('order_verified_'.$advert->getId());
|
||||
|
||||
// Si le webhook a deja traite le paiement, afficher la confirmation directe
|
||||
if (Advert::STATE_ACCEPTED === $advert->getState()) {
|
||||
return $this->render('order/payment_confirmed.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $advert->getCustomer(),
|
||||
'method' => 'stripe',
|
||||
]);
|
||||
}
|
||||
|
||||
// Verifier le statut du PaymentIntent directement aupres de Stripe
|
||||
$piId = $advert->getStripePaymentId();
|
||||
if (null !== $piId && '' !== $stripeSk) {
|
||||
try {
|
||||
\Stripe\Stripe::setApiKey($stripeSk);
|
||||
$pi = \Stripe\PaymentIntent::retrieve($piId);
|
||||
|
||||
if ('succeeded' === $pi->status) {
|
||||
return $this->render('order/payment_confirmed.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $advert->getCustomer(),
|
||||
'method' => 'stripe',
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fallback sur le loader
|
||||
}
|
||||
}
|
||||
|
||||
// Sinon afficher le loader de verification (le webhook va traiter en arriere-plan)
|
||||
$method = $request->query->getString('method', 'card');
|
||||
|
||||
return $this->render('order/payment_processing.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $advert->getCustomer(),
|
||||
'checkUrl' => $this->generateUrl('app_order_payment_stripe_check', ['numOrder' => $numOrder]),
|
||||
'method' => $method,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/order-choose/stripe/{numOrder}/check', name: 'app_order_payment_stripe_check', requirements: ['numOrder' => '.+'], methods: ['GET'])]
|
||||
public function stripeCheck(string $numOrder): JsonResponse
|
||||
{
|
||||
$advert = $this->findAdvert($numOrder);
|
||||
|
||||
return $this->json([
|
||||
'status' => $advert->getState(),
|
||||
'accepted' => Advert::STATE_ACCEPTED === $advert->getState(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le revendeur Stripe Connect associe au client (si eligible).
|
||||
*/
|
||||
private function findRevendeur(?\App\Entity\Customer $customer): ?Revendeur
|
||||
{
|
||||
if (null === $customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$code = $customer->getRevendeurCode();
|
||||
if (null === $code || '' === $code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$revendeur = $this->em->getRepository(Revendeur::class)->findOneBy(['codeRevendeur' => $code]);
|
||||
if (null === $revendeur) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verifie que le revendeur utilise Stripe Connect et a un ID connecte
|
||||
if (!$revendeur->isUseStripe() || null === $revendeur->getStripeConnectId() || '' === $revendeur->getStripeConnectId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $revendeur;
|
||||
}
|
||||
|
||||
private function findAdvert(string $numOrder): Advert
|
||||
{
|
||||
$advert = $this->em->createQueryBuilder()
|
||||
->select('a')
|
||||
->from(Advert::class, 'a')
|
||||
->join('a.orderNumber', 'o')
|
||||
->where('o.numOrder = :num')
|
||||
->andWhere('a.state != :cancel')
|
||||
->setParameter('num', $numOrder)
|
||||
->setParameter('cancel', Advert::STATE_CANCEL)
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
|
||||
@@ -26,9 +438,6 @@ class OrderPaymentController extends AbstractController
|
||||
throw $this->createNotFoundException('Avis de paiement introuvable.');
|
||||
}
|
||||
|
||||
return $this->render('order/payment.html.twig', [
|
||||
'advert' => $advert,
|
||||
'customer' => $advert->getCustomer(),
|
||||
]);
|
||||
return $advert;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use Twig\Environment;
|
||||
class WebhookDocuSealController extends AbstractController
|
||||
{
|
||||
private const ATTESTATION_NOT_FOUND = 'Attestation not found';
|
||||
private const MONITOR_EMAIL = 'monitor@siteconseil.fr';
|
||||
private const MONITOR_EMAIL = 'monitor@e-cosplay.fr';
|
||||
|
||||
#[Route('/webhooks/docuseal', name: 'app_webhook_docuseal', methods: ['POST'])]
|
||||
public function __invoke(
|
||||
@@ -383,7 +383,7 @@ class WebhookDocuSealController extends AbstractController
|
||||
|
||||
$mailer->sendEmail(
|
||||
$attestation->getEmail(),
|
||||
'CRM SITECONSEIL - Attestation signee '.$typeName.' ('.$attestation->getReference().')',
|
||||
'CRM E-Cosplay - Attestation signee '.$typeName.' ('.$attestation->getReference().')',
|
||||
$twig->render('emails/rgpd_attestation_signed.html.twig', [
|
||||
'attestation' => $attestation,
|
||||
'typeName' => $typeName,
|
||||
|
||||
@@ -2,20 +2,38 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\AdvertPayment;
|
||||
use App\Entity\Facture;
|
||||
use App\Entity\StripeWebhookSecret;
|
||||
use App\Repository\StripeWebhookSecretRepository;
|
||||
use App\Service\FactureService;
|
||||
use App\Service\MailerService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Twig\Environment;
|
||||
|
||||
class WebhookStripeController extends AbstractController
|
||||
{
|
||||
private const NOTIFICATION_EMAIL = 'notification@e-cosplay.fr';
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private StripeWebhookSecretRepository $secretRepository,
|
||||
private EntityManagerInterface $em,
|
||||
private MailerService $mailer,
|
||||
private Environment $twig,
|
||||
private FactureService $factureService,
|
||||
private KernelInterface $kernel,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -68,6 +86,281 @@ class WebhookStripeController extends AbstractController
|
||||
'type' => $event->type,
|
||||
]);
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]);
|
||||
// Dispatch par type d'evenement (main + connect)
|
||||
return match ($event->type) {
|
||||
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event, $channel),
|
||||
'payment_intent.payment_failed' => $this->handlePaymentFailed($event, $channel),
|
||||
default => new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]),
|
||||
};
|
||||
}
|
||||
|
||||
private function handlePaymentSucceeded(\Stripe\Event $event, string $channel): JsonResponse
|
||||
{
|
||||
$paymentIntent = $event->data->object;
|
||||
$metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []);
|
||||
$advertId = $metadata['advert_id'] ?? null;
|
||||
|
||||
if (null === $advertId) {
|
||||
$this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: pas de advert_id dans metadata', ['metadata' => $metadata]);
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'no_advert']);
|
||||
}
|
||||
|
||||
$advert = $this->em->getRepository(Advert::class)->find((int) $advertId);
|
||||
if (null === $advert) {
|
||||
$this->logger->warning('Stripe payment_intent.succeeded ['.$channel.']: advert introuvable', ['advert_id' => $advertId]);
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'advert_not_found']);
|
||||
}
|
||||
|
||||
// Eviter les doublons (webhook recu sur main ET connect pour le meme paiement)
|
||||
if (Advert::STATE_ACCEPTED === $advert->getState() && $advert->getStripePaymentId() === $paymentIntent->id) {
|
||||
$this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: deja traite', ['pi' => $paymentIntent->id]);
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'already_processed']);
|
||||
}
|
||||
|
||||
$amount = number_format($paymentIntent->amount_received / 100, 2, '.', '');
|
||||
$method = $metadata['payment_method'] ?? ($paymentIntent->payment_method_types[0] ?? null);
|
||||
$isConnect = str_contains($channel, 'connect');
|
||||
$revendeurCode = $metadata['revendeur_code'] ?? null;
|
||||
|
||||
// Determiner le label methode pour les mails
|
||||
$methodLabel = match ($method) {
|
||||
'sepa_debit' => 'Prelevement SEPA',
|
||||
'customer_balance' => 'Virement bancaire (Stripe)',
|
||||
'paypal' => 'PayPal',
|
||||
'klarna' => 'Klarna',
|
||||
'revolut_pay' => 'Revolut Pay',
|
||||
'amazon_pay' => 'Amazon Pay',
|
||||
'link' => 'Link (Stripe)',
|
||||
default => 'Carte bancaire',
|
||||
};
|
||||
|
||||
// Creer l'AdvertPayment
|
||||
$payment = new AdvertPayment($advert, AdvertPayment::TYPE_SUCCESS, $amount);
|
||||
$payment->setMethod($method);
|
||||
$this->em->persist($payment);
|
||||
|
||||
// Mettre a jour l'etat de l'avis
|
||||
$advert->setState(Advert::STATE_ACCEPTED);
|
||||
$advert->setStripePaymentId($paymentIntent->id);
|
||||
|
||||
// Tracker l'evenement paiement
|
||||
$this->em->persist(new \App\Entity\AdvertEvent($advert, \App\Entity\AdvertEvent::TYPE_PAY, $methodLabel.' - '.$amount.' EUR'));
|
||||
$this->em->flush();
|
||||
|
||||
// Generation automatique de la facture si montant paye == montant avis
|
||||
$facture = null;
|
||||
try {
|
||||
$facture = $this->factureService->createPaidFactureFromAdvert($advert, $amount, $methodLabel);
|
||||
|
||||
// Generer le PDF + envoyer au client
|
||||
if (null !== $facture) {
|
||||
$this->generateAndSendFacture($facture);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Stripe webhook: erreur generation facture: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$customer = $advert->getCustomer();
|
||||
$numOrder = $advert->getOrderNumber()->getNumOrder();
|
||||
|
||||
// Mail client : confirmation paiement
|
||||
if (null !== $customer && null !== $customer->getEmail()) {
|
||||
try {
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Paiement confirme - Avis '.$numOrder,
|
||||
$this->twig->render('emails/payment_stripe_success_client.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'amount' => $amount,
|
||||
'methodLabel' => $methodLabel,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Stripe webhook: erreur envoi mail client: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Notification admin
|
||||
$adminSubject = ($isConnect ? '[Connect] ' : '').'Paiement recu ('.$methodLabel.') - '.$numOrder.' - '.($customer?->getFullName() ?? 'Inconnu');
|
||||
if (null !== $revendeurCode) {
|
||||
$adminSubject .= ' [Revendeur '.$revendeurCode.']';
|
||||
}
|
||||
|
||||
try {
|
||||
$this->mailer->sendEmail(
|
||||
self::NOTIFICATION_EMAIL,
|
||||
$adminSubject,
|
||||
$this->twig->render('emails/payment_notification_admin.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'method' => $methodLabel.' - '.$amount.' EUR'.($isConnect && null !== $revendeurCode ? ' (via revendeur '.$revendeurCode.')' : ''),
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Stripe webhook: erreur envoi mail admin: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->logger->info('Stripe payment_intent.succeeded ['.$channel.']: advert '.$numOrder.' -> accepted, '.$amount.' EUR, methode '.$method.($isConnect ? ' (connect)' : ''));
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'payment_accepted', 'advert' => $numOrder, 'amount' => $amount, 'method' => $method, 'channel' => $channel]);
|
||||
}
|
||||
|
||||
private function handlePaymentFailed(\Stripe\Event $event, string $channel): JsonResponse
|
||||
{
|
||||
$paymentIntent = $event->data->object;
|
||||
$metadata = $paymentIntent->metadata instanceof \Stripe\StripeObject ? $paymentIntent->metadata->toArray() : (array) ($paymentIntent->metadata ?? []);
|
||||
$advertId = $metadata['advert_id'] ?? null;
|
||||
|
||||
if (null === $advertId) {
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'no_advert']);
|
||||
}
|
||||
|
||||
$advert = $this->em->getRepository(Advert::class)->find((int) $advertId);
|
||||
if (null === $advert) {
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'advert_not_found']);
|
||||
}
|
||||
|
||||
$amount = number_format(($paymentIntent->amount ?? 0) / 100, 2, '.', '');
|
||||
$method = $metadata['payment_method'] ?? ($paymentIntent->payment_method_types[0] ?? null);
|
||||
$errorMessage = $paymentIntent->last_payment_error?->message ?? 'Paiement refuse';
|
||||
|
||||
$methodLabel = match ($method) {
|
||||
'sepa_debit' => 'Prelevement SEPA',
|
||||
'customer_balance' => 'Virement bancaire (Stripe)',
|
||||
'paypal' => 'PayPal',
|
||||
default => 'Carte bancaire',
|
||||
};
|
||||
|
||||
// Creer l'AdvertPayment refused
|
||||
$payment = new AdvertPayment($advert, AdvertPayment::TYPE_REFUSED, $amount);
|
||||
$payment->setMethod($method);
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
$customer = $advert->getCustomer();
|
||||
$numOrder = $advert->getOrderNumber()->getNumOrder();
|
||||
$revendeurCode = $metadata['revendeur_code'] ?? null;
|
||||
$isConnect = str_contains($channel, 'connect');
|
||||
|
||||
// Mail client : notification du refus de paiement
|
||||
if (null !== $customer && null !== $customer->getEmail()) {
|
||||
try {
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Paiement refuse - Avis '.$numOrder,
|
||||
$this->twig->render('emails/payment_stripe_failed_client.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'amount' => $amount,
|
||||
'methodLabel' => $methodLabel,
|
||||
'errorMessage' => $errorMessage,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Stripe webhook: erreur envoi mail client echec: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Notification admin
|
||||
try {
|
||||
$this->mailer->sendEmail(
|
||||
self::NOTIFICATION_EMAIL,
|
||||
($isConnect ? '[Connect] ' : '').'Paiement echoue ('.$methodLabel.') - '.$numOrder.' - '.($customer?->getFullName() ?? 'Inconnu').($revendeurCode ? ' [Revendeur '.$revendeurCode.']' : ''),
|
||||
$this->twig->render('emails/payment_notification_admin.html.twig', [
|
||||
'customer' => $customer,
|
||||
'advert' => $advert,
|
||||
'method' => $methodLabel.' refuse - '.$errorMessage,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Stripe webhook: erreur envoi mail admin echec: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->logger->warning('Stripe payment_intent.payment_failed ['.$channel.']: advert '.$numOrder.' - '.$methodLabel.' - '.$errorMessage);
|
||||
|
||||
return new JsonResponse(['status' => 'ok', 'action' => 'payment_failed', 'advert' => $numOrder, 'method' => $method]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genere le PDF de la facture, le sauvegarde via Vich, et l'envoie au client par mail.
|
||||
*/
|
||||
private function generateAndSendFacture(Facture $facture): void
|
||||
{
|
||||
$customer = $facture->getCustomer();
|
||||
|
||||
// 1. Generer le PDF via FPDF
|
||||
$pdf = new \App\Service\Pdf\FacturePdf($this->kernel, $facture, $this->urlGenerator, $this->twig);
|
||||
$pdf->generate();
|
||||
|
||||
$tmpPath = tempnam(sys_get_temp_dir(), 'facture_').'.pdf';
|
||||
$pdf->Output('F', $tmpPath);
|
||||
|
||||
$uploadedFile = new UploadedFile(
|
||||
$tmpPath,
|
||||
'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf',
|
||||
'application/pdf',
|
||||
null,
|
||||
true
|
||||
);
|
||||
|
||||
$facture->setFacturePdfFile($uploadedFile);
|
||||
$facture->setUpdatedAt(new \DateTimeImmutable());
|
||||
$facture->setState(Facture::STATE_SEND);
|
||||
$this->em->flush();
|
||||
|
||||
@unlink($tmpPath);
|
||||
|
||||
$this->logger->info('Stripe webhook: PDF facture '.$facture->getInvoiceNumber().' genere');
|
||||
|
||||
// 2. Envoyer au client
|
||||
if (null === $customer || null === $customer->getEmail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectDir = $this->kernel->getProjectDir();
|
||||
$attachments = [];
|
||||
if (null !== $facture->getFacturePdf()) {
|
||||
$pdfPath = $projectDir.'/public/uploads/factures/'.$facture->getFacturePdf();
|
||||
if (file_exists($pdfPath)) {
|
||||
$attachments[] = ['path' => $pdfPath, 'name' => 'facture-'.str_replace('/', '-', $facture->getInvoiceNumber()).'.pdf'];
|
||||
}
|
||||
}
|
||||
|
||||
$verifyUrl = $this->urlGenerator->generate('app_facture_verify', [
|
||||
'id' => $facture->getId(),
|
||||
'hmac' => $facture->getHmac(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$customer->getEmail(),
|
||||
'Facture '.$facture->getInvoiceNumber(),
|
||||
$this->twig->render('emails/facture_send.html.twig', [
|
||||
'customer' => $customer,
|
||||
'facture' => $facture,
|
||||
'verifyUrl' => $verifyUrl,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
$attachments,
|
||||
);
|
||||
|
||||
$this->logger->info('Stripe webhook: facture '.$facture->getInvoiceNumber().' envoyee a '.$customer->getEmail());
|
||||
}
|
||||
}
|
||||
|
||||
193
src/Entity/ActionLog.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['customer_id'], name: 'idx_action_log_customer')]
|
||||
#[ORM\Index(columns: ['action'], name: 'idx_action_log_action')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_action_log_created')]
|
||||
class ActionLog
|
||||
{
|
||||
public const ACTION_SUSPEND_CUSTOMER = 'suspend_customer';
|
||||
public const ACTION_UNSUSPEND_CUSTOMER = 'unsuspend_customer';
|
||||
public const ACTION_SUSPEND_WEBSITE = 'suspend_website';
|
||||
public const ACTION_UNSUSPEND_WEBSITE = 'unsuspend_website';
|
||||
public const ACTION_SUSPEND_DOMAIN_EMAIL = 'suspend_domain_email';
|
||||
public const ACTION_UNSUSPEND_DOMAIN_EMAIL = 'unsuspend_domain_email';
|
||||
public const ACTION_DISABLE_CUSTOMER = 'disable_customer';
|
||||
public const ACTION_DELETE_CUSTOMER_DATA = 'delete_customer_data';
|
||||
public const ACTION_FORMAL_NOTICE = 'formal_notice';
|
||||
public const ACTION_TERMINATION = 'termination';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
private string $action;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Customer $customer = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $entityId = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $entityType = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $message;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $context = null;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
private string $severity;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $previousState = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $newState = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $success;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $errorMessage = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(
|
||||
string $action,
|
||||
string $message,
|
||||
string $severity = 'info',
|
||||
bool $success = true,
|
||||
) {
|
||||
$this->action = $action;
|
||||
$this->message = $message;
|
||||
$this->severity = $severity;
|
||||
$this->success = $success;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
public function getCustomer(): ?Customer
|
||||
{
|
||||
return $this->customer;
|
||||
}
|
||||
|
||||
public function setCustomer(?Customer $customer): static
|
||||
{
|
||||
$this->customer = $customer;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityId(): ?int
|
||||
{
|
||||
return $this->entityId;
|
||||
}
|
||||
|
||||
public function setEntityId(?int $entityId): static
|
||||
{
|
||||
$this->entityId = $entityId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityType(): ?string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
public function setEntityType(?string $entityType): static
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMessage(): string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function getContext(): ?string
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
public function setContext(?string $context): static
|
||||
{
|
||||
$this->context = $context;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSeverity(): string
|
||||
{
|
||||
return $this->severity;
|
||||
}
|
||||
|
||||
public function getPreviousState(): ?string
|
||||
{
|
||||
return $this->previousState;
|
||||
}
|
||||
|
||||
public function setPreviousState(?string $previousState): static
|
||||
{
|
||||
$this->previousState = $previousState;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNewState(): ?string
|
||||
{
|
||||
return $this->newState;
|
||||
}
|
||||
|
||||
public function setNewState(?string $newState): static
|
||||
{
|
||||
$this->newState = $newState;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
public function getErrorMessage(): ?string
|
||||
{
|
||||
return $this->errorMessage;
|
||||
}
|
||||
|
||||
public function setErrorMessage(?string $errorMessage): static
|
||||
{
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->success = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ class Advert
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $submissionId = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $stripePaymentId = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $advertFile = null;
|
||||
|
||||
@@ -78,12 +81,18 @@ class Advert
|
||||
#[ORM\OrderBy(['pos' => 'ASC'])]
|
||||
private Collection $lines;
|
||||
|
||||
/** @var Collection<int, AdvertPayment> */
|
||||
#[ORM\OneToMany(targetEntity: AdvertPayment::class, mappedBy: 'advert', cascade: ['persist', 'remove'])]
|
||||
#[ORM\OrderBy(['createdAt' => 'DESC'])]
|
||||
private Collection $payments;
|
||||
|
||||
public function __construct(OrderNumber $orderNumber, string $hmacSecret)
|
||||
{
|
||||
$this->orderNumber = $orderNumber;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->factures = new ArrayCollection();
|
||||
$this->lines = new ArrayCollection();
|
||||
$this->payments = new ArrayCollection();
|
||||
$this->hmac = $this->generateHmac($hmacSecret);
|
||||
}
|
||||
|
||||
@@ -182,6 +191,16 @@ class Advert
|
||||
$this->submissionId = $submissionId;
|
||||
}
|
||||
|
||||
public function getStripePaymentId(): ?string
|
||||
{
|
||||
return $this->stripePaymentId;
|
||||
}
|
||||
|
||||
public function setStripePaymentId(?string $stripePaymentId): void
|
||||
{
|
||||
$this->stripePaymentId = $stripePaymentId;
|
||||
}
|
||||
|
||||
public function getAdvertFile(): ?string
|
||||
{
|
||||
return $this->advertFile;
|
||||
@@ -250,6 +269,12 @@ class Advert
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, AdvertPayment> */
|
||||
public function getPayments(): Collection
|
||||
{
|
||||
return $this->payments;
|
||||
}
|
||||
|
||||
public function verifyHmac(string $hmacSecret): bool
|
||||
{
|
||||
return hash_equals($this->hmac, $this->generateHmac($hmacSecret));
|
||||
|
||||
89
src/Entity/AdvertEvent.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['advert_id'], name: 'idx_advert_event_advert')]
|
||||
#[ORM\Index(columns: ['type'], name: 'idx_advert_event_type')]
|
||||
class AdvertEvent
|
||||
{
|
||||
public const TYPE_VIEW = 'view';
|
||||
public const TYPE_PAY = 'pay';
|
||||
public const TYPE_MAIL_OPEN = 'mail_open';
|
||||
public const TYPE_MAIL_SEND = 'mail_send';
|
||||
public const TYPE_REMINDER = 'reminder';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Advert::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Advert $advert;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $details = null;
|
||||
|
||||
#[ORM\Column(length: 45, nullable: true)]
|
||||
private ?string $ip = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(Advert $advert, string $type, ?string $details = null, ?string $ip = null)
|
||||
{
|
||||
$this->advert = $advert;
|
||||
$this->type = $type;
|
||||
$this->details = $details;
|
||||
$this->ip = $ip;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAdvert(): Advert
|
||||
{
|
||||
return $this->advert;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getDetails(): ?string
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
|
||||
public function getIp(): ?string
|
||||
{
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
self::TYPE_VIEW => 'Page consultee',
|
||||
self::TYPE_PAY => 'Paiement effectue',
|
||||
self::TYPE_MAIL_OPEN => 'Email ouvert',
|
||||
self::TYPE_MAIL_SEND => 'Email envoye',
|
||||
self::TYPE_REMINDER => 'Relance envoyee',
|
||||
default => $this->type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@ class AdvertLine
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $priceHt = '0.00';
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $serviceId = null;
|
||||
|
||||
public function __construct(Advert $advert, string $title, string $priceHt = '0.00', int $pos = 0)
|
||||
{
|
||||
$this->advert = $advert;
|
||||
@@ -93,4 +99,28 @@ class AdvertLine
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getServiceId(): ?int
|
||||
{
|
||||
return $this->serviceId;
|
||||
}
|
||||
|
||||
public function setServiceId(?int $serviceId): static
|
||||
{
|
||||
$this->serviceId = $serviceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
80
src/Entity/AdvertPayment.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['advert_id'], name: 'idx_advert_payment_advert')]
|
||||
#[ORM\Index(columns: ['type'], name: 'idx_advert_payment_type')]
|
||||
class AdvertPayment
|
||||
{
|
||||
public const TYPE_SUCCESS = 'success';
|
||||
public const TYPE_REFUSED = 'refused';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Advert::class, inversedBy: 'payments')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Advert $advert;
|
||||
|
||||
#[ORM\Column(length: 20)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
|
||||
private string $amount;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $method = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(Advert $advert, string $type, string $amount)
|
||||
{
|
||||
$this->advert = $advert;
|
||||
$this->type = $type;
|
||||
$this->amount = $amount;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAdvert(): Advert
|
||||
{
|
||||
return $this->advert;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getAmount(): string
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function getMethod(): ?string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function setMethod(?string $method): static
|
||||
{
|
||||
$this->method = $method;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,6 @@ class Customer
|
||||
|
||||
public function generateCodeComptable(): string
|
||||
{
|
||||
$prefix = '411';
|
||||
$idPart = str_pad((string) ($this->id ?? 0), 4, '0', STR_PAD_LEFT);
|
||||
$namePart = '';
|
||||
|
||||
@@ -164,7 +163,7 @@ class Customer
|
||||
|
||||
$namePart = str_pad($namePart, 5, 'X');
|
||||
|
||||
return $prefix.'_'.$idPart.'_'.$namePart;
|
||||
return 'EC-'.$idPart.'-'.$namePart;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
|
||||
@@ -28,6 +28,12 @@ class DevisLine
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $priceHt = '0.00';
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $serviceId = null;
|
||||
|
||||
public function __construct(Devis $devis, string $title, string $priceHt = '0.00', int $pos = 0)
|
||||
{
|
||||
$this->devis = $devis;
|
||||
@@ -93,4 +99,28 @@ class DevisLine
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getServiceId(): ?int
|
||||
{
|
||||
return $this->serviceId;
|
||||
}
|
||||
|
||||
public function setServiceId(?int $serviceId): static
|
||||
{
|
||||
$this->serviceId = $serviceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,21 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\FactureRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Vich\UploaderBundle\Mapping\Attribute as Vich;
|
||||
|
||||
#[ORM\Entity(repositoryClass: FactureRepository::class)]
|
||||
#[Vich\Uploadable]
|
||||
class Facture
|
||||
{
|
||||
public const STATE_CREATED = 'created';
|
||||
public const STATE_SEND = 'send';
|
||||
public const STATE_PAID = 'paid';
|
||||
public const STATE_CANCEL = 'cancel';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -21,6 +31,10 @@ class Facture
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?Advert $advert = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Customer::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Customer $customer = null;
|
||||
|
||||
/**
|
||||
* Suffixe pour les factures multiples sur un meme advert (-1, -2, etc.).
|
||||
*/
|
||||
@@ -30,13 +44,49 @@ class Facture
|
||||
#[ORM\Column(length: 128)]
|
||||
private string $hmac;
|
||||
|
||||
#[ORM\Column(length: 20, options: ['default' => 'created'])]
|
||||
private string $state = self::STATE_CREATED;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $totalHt = '0.00';
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $totalTva = '0.00';
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $totalTtc = '0.00';
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isPaid = false;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $paidAt = null;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $paidMethod = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $facturePdf = null;
|
||||
|
||||
#[Vich\UploadableField(mapping: 'facture_pdf', fileNameProperty: 'facturePdf')]
|
||||
private ?File $facturePdfFile = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
/** @var Collection<int, FactureLine> */
|
||||
#[ORM\OneToMany(targetEntity: FactureLine::class, mappedBy: 'facture', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['pos' => 'ASC'])]
|
||||
private Collection $lines;
|
||||
|
||||
public function __construct(OrderNumber $orderNumber, string $hmacSecret)
|
||||
{
|
||||
$this->orderNumber = $orderNumber;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->lines = new ArrayCollection();
|
||||
$this->hmac = $this->generateHmac($hmacSecret);
|
||||
}
|
||||
|
||||
@@ -60,6 +110,16 @@ class Facture
|
||||
$this->advert = $advert;
|
||||
}
|
||||
|
||||
public function getCustomer(): ?Customer
|
||||
{
|
||||
return $this->customer;
|
||||
}
|
||||
|
||||
public function setCustomer(?Customer $customer): void
|
||||
{
|
||||
$this->customer = $customer;
|
||||
}
|
||||
|
||||
public function getSplitIndex(): int
|
||||
{
|
||||
return $this->splitIndex;
|
||||
@@ -86,11 +146,138 @@ class Facture
|
||||
return $this->hmac;
|
||||
}
|
||||
|
||||
public function getState(): string
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function setState(string $state): void
|
||||
{
|
||||
$this->state = $state;
|
||||
}
|
||||
|
||||
public function getTotalHt(): string
|
||||
{
|
||||
return $this->totalHt;
|
||||
}
|
||||
|
||||
public function setTotalHt(string $totalHt): void
|
||||
{
|
||||
$this->totalHt = $totalHt;
|
||||
}
|
||||
|
||||
public function getTotalTva(): string
|
||||
{
|
||||
return $this->totalTva;
|
||||
}
|
||||
|
||||
public function setTotalTva(string $totalTva): void
|
||||
{
|
||||
$this->totalTva = $totalTva;
|
||||
}
|
||||
|
||||
public function getTotalTtc(): string
|
||||
{
|
||||
return $this->totalTtc;
|
||||
}
|
||||
|
||||
public function setTotalTtc(string $totalTtc): void
|
||||
{
|
||||
$this->totalTtc = $totalTtc;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->isPaid;
|
||||
}
|
||||
|
||||
public function setIsPaid(bool $isPaid): void
|
||||
{
|
||||
$this->isPaid = $isPaid;
|
||||
}
|
||||
|
||||
public function getPaidAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->paidAt;
|
||||
}
|
||||
|
||||
public function setPaidAt(?\DateTimeImmutable $paidAt): void
|
||||
{
|
||||
$this->paidAt = $paidAt;
|
||||
}
|
||||
|
||||
public function getPaidMethod(): ?string
|
||||
{
|
||||
return $this->paidMethod;
|
||||
}
|
||||
|
||||
public function setPaidMethod(?string $paidMethod): void
|
||||
{
|
||||
$this->paidMethod = $paidMethod;
|
||||
}
|
||||
|
||||
public function getFacturePdf(): ?string
|
||||
{
|
||||
return $this->facturePdf;
|
||||
}
|
||||
|
||||
public function setFacturePdf(?string $facturePdf): void
|
||||
{
|
||||
$this->facturePdf = $facturePdf;
|
||||
}
|
||||
|
||||
public function getFacturePdfFile(): ?File
|
||||
{
|
||||
return $this->facturePdfFile;
|
||||
}
|
||||
|
||||
public function setFacturePdfFile(?File $facturePdfFile): void
|
||||
{
|
||||
$this->facturePdfFile = $facturePdfFile;
|
||||
if (null !== $facturePdfFile) {
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, FactureLine> */
|
||||
public function getLines(): Collection
|
||||
{
|
||||
return $this->lines;
|
||||
}
|
||||
|
||||
public function addLine(FactureLine $line): static
|
||||
{
|
||||
if (!$this->lines->contains($line)) {
|
||||
$this->lines->add($line);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeLine(FactureLine $line): static
|
||||
{
|
||||
$this->lines->removeElement($line);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function verifyHmac(string $hmacSecret): bool
|
||||
{
|
||||
return hash_equals($this->hmac, $this->generateHmac($hmacSecret));
|
||||
|
||||
126
src/Entity/FactureLine.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
class FactureLine
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Facture::class, inversedBy: 'lines')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Facture $facture;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $pos = 0;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $priceHt = '0.00';
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $serviceId = null;
|
||||
|
||||
public function __construct(Facture $facture, string $title, string $priceHt = '0.00', int $pos = 0)
|
||||
{
|
||||
$this->facture = $facture;
|
||||
$this->title = $title;
|
||||
$this->priceHt = $priceHt;
|
||||
$this->pos = $pos;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getFacture(): Facture
|
||||
{
|
||||
return $this->facture;
|
||||
}
|
||||
|
||||
public function getPos(): int
|
||||
{
|
||||
return $this->pos;
|
||||
}
|
||||
|
||||
public function setPos(int $pos): static
|
||||
{
|
||||
$this->pos = $pos;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPriceHt(): string
|
||||
{
|
||||
return $this->priceHt;
|
||||
}
|
||||
|
||||
public function setPriceHt(string $priceHt): static
|
||||
{
|
||||
$this->priceHt = $priceHt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getServiceId(): ?int
|
||||
{
|
||||
return $this->serviceId;
|
||||
}
|
||||
|
||||
public function setServiceId(?int $serviceId): static
|
||||
{
|
||||
$this->serviceId = $serviceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
203
src/Entity/FacturePrestataire.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Vich\UploaderBundle\Mapping\Attribute as Vich;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['prestataire_id', 'year', 'month'], name: 'idx_facture_presta_period')]
|
||||
#[Vich\Uploadable]
|
||||
class FacturePrestataire
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Prestataire::class, inversedBy: 'factures')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Prestataire $prestataire;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $numFacture;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $montantHt = '0.00';
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, options: ['default' => '0.00'])]
|
||||
private string $montantTtc = '0.00';
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $year;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $month;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $isPaid = false;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $paidAt = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $facturePdf = null;
|
||||
|
||||
#[Vich\UploadableField(mapping: 'facture_prestataire_pdf', fileNameProperty: 'facturePdf')]
|
||||
private ?File $facturePdfFile = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public function __construct(Prestataire $prestataire, string $numFacture, int $year, int $month)
|
||||
{
|
||||
$this->prestataire = $prestataire;
|
||||
$this->numFacture = $numFacture;
|
||||
$this->year = $year;
|
||||
$this->month = $month;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPrestataire(): Prestataire
|
||||
{
|
||||
return $this->prestataire;
|
||||
}
|
||||
|
||||
public function getNumFacture(): string
|
||||
{
|
||||
return $this->numFacture;
|
||||
}
|
||||
|
||||
public function setNumFacture(string $numFacture): static
|
||||
{
|
||||
$this->numFacture = $numFacture;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMontantHt(): string
|
||||
{
|
||||
return $this->montantHt;
|
||||
}
|
||||
|
||||
public function setMontantHt(string $montantHt): static
|
||||
{
|
||||
$this->montantHt = $montantHt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMontantTtc(): string
|
||||
{
|
||||
return $this->montantTtc;
|
||||
}
|
||||
|
||||
public function setMontantTtc(string $montantTtc): static
|
||||
{
|
||||
$this->montantTtc = $montantTtc;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getYear(): int
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
public function setYear(int $year): static
|
||||
{
|
||||
$this->year = $year;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMonth(): int
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function setMonth(int $month): static
|
||||
{
|
||||
$this->month = $month;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label du mois en francais (ex: "Avril 2026").
|
||||
*/
|
||||
public function getPeriodLabel(): string
|
||||
{
|
||||
$months = [1 => 'Janvier', 'Fevrier', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Aout', 'Septembre', 'Octobre', 'Novembre', 'Decembre'];
|
||||
|
||||
return ($months[$this->month] ?? $this->month).' '.$this->year;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->isPaid;
|
||||
}
|
||||
|
||||
public function setIsPaid(bool $isPaid): static
|
||||
{
|
||||
$this->isPaid = $isPaid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPaidAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->paidAt;
|
||||
}
|
||||
|
||||
public function setPaidAt(?\DateTimeImmutable $paidAt): static
|
||||
{
|
||||
$this->paidAt = $paidAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFacturePdf(): ?string
|
||||
{
|
||||
return $this->facturePdf;
|
||||
}
|
||||
|
||||
public function setFacturePdf(?string $facturePdf): static
|
||||
{
|
||||
$this->facturePdf = $facturePdf;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFacturePdfFile(): ?File
|
||||
{
|
||||
return $this->facturePdfFile;
|
||||
}
|
||||
|
||||
public function setFacturePdfFile(?File $facturePdfFile): void
|
||||
{
|
||||
$this->facturePdfFile = $facturePdfFile;
|
||||
if (null !== $facturePdfFile) {
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
}
|
||||
93
src/Entity/PaymentReminder.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Index(columns: ['advert_id'], name: 'idx_payment_reminder_advert')]
|
||||
#[ORM\Index(columns: ['step'], name: 'idx_payment_reminder_step')]
|
||||
class PaymentReminder
|
||||
{
|
||||
// Etapes de relance (jour apres envoi de l'avis)
|
||||
public const STEP_REMINDER_15 = 'reminder_15'; // J+15 : Rappel
|
||||
public const STEP_WARNING_10 = 'warning_10'; // J+10 : Rappel + avertissement
|
||||
public const STEP_SUSPENSION_WARNING_5 = 'suspension_5'; // J+5 : Avertissement suspension services
|
||||
public const STEP_FINAL_REMINDER_3 = 'final_reminder_3'; // J+3 : Ultime rappel
|
||||
public const STEP_SUSPENSION_1 = 'suspension_1'; // J+1 : Suspension des services
|
||||
public const STEP_FORMAL_NOTICE = 'formal_notice'; // J-1 (apres echeance) : Mise en demeure
|
||||
public const STEP_TERMINATION_WARNING = 'termination_15'; // J+15 post-echeance : Avertissement resiliation + suppression donnees
|
||||
public const STEP_TERMINATION = 'termination_30'; // J+30 post-echeance : Resiliation + suppression + recouvrement legal
|
||||
|
||||
public const STEPS_CONFIG = [
|
||||
self::STEP_REMINDER_15 => ['days' => 15, 'label' => 'Rappel de paiement', 'severity' => 'info'],
|
||||
self::STEP_WARNING_10 => ['days' => 20, 'label' => 'Rappel + avertissement', 'severity' => 'warning'],
|
||||
self::STEP_SUSPENSION_WARNING_5 => ['days' => 25, 'label' => 'Avertissement suspension services', 'severity' => 'danger'],
|
||||
self::STEP_FINAL_REMINDER_3 => ['days' => 27, 'label' => 'Ultime rappel', 'severity' => 'danger'],
|
||||
self::STEP_SUSPENSION_1 => ['days' => 29, 'label' => 'Suspension des services', 'severity' => 'critical'],
|
||||
self::STEP_FORMAL_NOTICE => ['days' => 31, 'label' => 'Mise en demeure', 'severity' => 'critical'],
|
||||
self::STEP_TERMINATION_WARNING => ['days' => 45, 'label' => 'Avertissement resiliation + suppression donnees', 'severity' => 'critical'],
|
||||
self::STEP_TERMINATION => ['days' => 60, 'label' => 'Resiliation contrat + recouvrement legal', 'severity' => 'critical'],
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Advert::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Advert $advert;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
private string $step;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $sentAt;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $details = null;
|
||||
|
||||
public function __construct(Advert $advert, string $step, ?string $details = null)
|
||||
{
|
||||
$this->advert = $advert;
|
||||
$this->step = $step;
|
||||
$this->details = $details;
|
||||
$this->sentAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAdvert(): Advert
|
||||
{
|
||||
return $this->advert;
|
||||
}
|
||||
|
||||
public function getStep(): string
|
||||
{
|
||||
return $this->step;
|
||||
}
|
||||
|
||||
public function getSentAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->sentAt;
|
||||
}
|
||||
|
||||
public function getDetails(): ?string
|
||||
{
|
||||
return $this->details;
|
||||
}
|
||||
|
||||
public function getStepLabel(): string
|
||||
{
|
||||
return self::STEPS_CONFIG[$this->step]['label'] ?? $this->step;
|
||||
}
|
||||
|
||||
public function getSeverity(): string
|
||||
{
|
||||
return self::STEPS_CONFIG[$this->step]['severity'] ?? 'info';
|
||||
}
|
||||
}
|
||||
209
src/Entity/Prestataire.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\PrestataireRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: PrestataireRepository::class)]
|
||||
class Prestataire
|
||||
{
|
||||
public const STATE_ACTIVE = 'active';
|
||||
public const STATE_INACTIVE = 'inactive';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $raisonSociale;
|
||||
|
||||
#[ORM\Column(length: 14, nullable: true)]
|
||||
private ?string $siret = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 500, nullable: true)]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(length: 10, nullable: true)]
|
||||
private ?string $zipCode = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 20, options: ['default' => 'active'])]
|
||||
private string $state = self::STATE_ACTIVE;
|
||||
|
||||
#[ORM\Column]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
/** @var Collection<int, FacturePrestataire> */
|
||||
#[ORM\OneToMany(targetEntity: FacturePrestataire::class, mappedBy: 'prestataire', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['year' => 'DESC', 'month' => 'DESC'])]
|
||||
private Collection $factures;
|
||||
|
||||
public function __construct(string $raisonSociale)
|
||||
{
|
||||
$this->raisonSociale = $raisonSociale;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->factures = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getRaisonSociale(): string
|
||||
{
|
||||
return $this->raisonSociale;
|
||||
}
|
||||
|
||||
public function setRaisonSociale(string $raisonSociale): static
|
||||
{
|
||||
$this->raisonSociale = $raisonSociale;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSiret(): ?string
|
||||
{
|
||||
return $this->siret;
|
||||
}
|
||||
|
||||
public function setSiret(?string $siret): static
|
||||
{
|
||||
$this->siret = $siret;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAddress(): ?string
|
||||
{
|
||||
return $this->address;
|
||||
}
|
||||
|
||||
public function setAddress(?string $address): static
|
||||
{
|
||||
$this->address = $address;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getZipCode(): ?string
|
||||
{
|
||||
return $this->zipCode;
|
||||
}
|
||||
|
||||
public function setZipCode(?string $zipCode): static
|
||||
{
|
||||
$this->zipCode = $zipCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCity(): ?string
|
||||
{
|
||||
return $this->city;
|
||||
}
|
||||
|
||||
public function setCity(?string $city): static
|
||||
{
|
||||
$this->city = $city;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getState(): string
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function setState(string $state): static
|
||||
{
|
||||
$this->state = $state;
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/** @return Collection<int, FacturePrestataire> */
|
||||
public function getFactures(): Collection
|
||||
{
|
||||
return $this->factures;
|
||||
}
|
||||
|
||||
public function addFacture(FacturePrestataire $facture): static
|
||||
{
|
||||
if (!$this->factures->contains($facture)) {
|
||||
$this->factures->add($facture);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFullAddress(): string
|
||||
{
|
||||
return trim(($this->address ?? '').' '.($this->zipCode ?? '').' '.($this->city ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Total HT de toutes les factures payees.
|
||||
*/
|
||||
public function getTotalPaidHt(): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($this->factures as $f) {
|
||||
if ($f->isPaid()) {
|
||||
$total += (float) $f->getMontantHt();
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
class SubdomainRedirectListener
|
||||
{
|
||||
private const SUBDOMAIN_ROUTES = [
|
||||
'webmail.siteconseil.fr' => 'app_webmail_login',
|
||||
'status.siteconseil.fr' => 'app_status',
|
||||
'webmail.e-cosplay.fr' => 'app_webmail_login',
|
||||
'status.e-cosplay.fr' => 'app_status',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
18
src/Repository/PrestataireRepository.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Prestataire;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Prestataire>
|
||||
*/
|
||||
class PrestataireRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Prestataire::class);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
|
||||
$lastName = $keycloakData['family_name'] ?? '';
|
||||
$groups = $keycloakData['groups'] ?? [];
|
||||
|
||||
// Securite : verifier que l'email appartient au domaine @e-cosplay.fr
|
||||
if ('' === $email || !str_contains($email, '@e-cosplay.fr')) {
|
||||
throw new AuthenticationException('Acces refuse : votre adresse email n\'appartient pas au domaine @e-cosplay.fr');
|
||||
}
|
||||
|
||||
// Chercher l'utilisateur par keycloakId ou email
|
||||
$user = $this->userRepository->findOneBy(['keycloakId' => $keycloakId])
|
||||
?? $this->userRepository->findOneBy(['email' => $email]);
|
||||
@@ -85,7 +90,7 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
|
||||
{
|
||||
$session = $request->getSession();
|
||||
if ($session instanceof FlashBagAwareSessionInterface) {
|
||||
$session->getFlashBag()->add('error', 'Echec de la connexion SITECONSEIL : '.$exception->getMessage());
|
||||
$session->getFlashBag()->add('error', 'Echec de la connexion E-Cosplay : '.$exception->getMessage());
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->router->generate('app_home'));
|
||||
@@ -103,10 +108,18 @@ class KeycloakAuthenticator extends OAuth2Authenticator implements Authenticatio
|
||||
*/
|
||||
private function resolveRoles(array $groups): array
|
||||
{
|
||||
if (\in_array('siteconseil_admin', $groups, true)) {
|
||||
if (\in_array('superadmin', $groups, true)) {
|
||||
return ['ROLE_ROOT'];
|
||||
}
|
||||
|
||||
return ['ROLE_EMPLOYE'];
|
||||
if (\in_array('super_admin_asso', $groups, true)) {
|
||||
return ['ROLE_ROOT'];
|
||||
}
|
||||
|
||||
if (\in_array('gp_member', $groups, true)) {
|
||||
return ['ROLE_EMPLOYE'];
|
||||
}
|
||||
|
||||
return ['ROLE_USER'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ class TwoFactorCodeMailer implements AuthCodeMailerInterface
|
||||
]);
|
||||
|
||||
$email = (new Email())
|
||||
->from('CRM SITECONSEIL <contact@siteconseil.fr>')
|
||||
->from('CRM E-Cosplay <contact@e-cosplay.fr>')
|
||||
->to($user->getEmailAuthRecipient())
|
||||
->subject('CRM SITECONSEIL - Code de verification')
|
||||
->subject('CRM E-Cosplay - Code de verification')
|
||||
->html($html);
|
||||
|
||||
$this->mailer->send($email);
|
||||
|
||||
314
src/Service/ActionService.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ActionLog;
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Domain;
|
||||
use App\Entity\DomainEmail;
|
||||
use App\Entity\Website;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Service centralise pour les actions destructrices et critiques.
|
||||
* Chaque action est loggee de facon exhaustive (ActionLog + Psr\Log).
|
||||
*/
|
||||
class ActionService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private LoggerInterface $logger,
|
||||
private MailerService $mailer,
|
||||
) {
|
||||
}
|
||||
|
||||
// ──────── Suspension client ────────
|
||||
|
||||
/**
|
||||
* Suspend un client : desactive le compte + suspend tous ses sites + suspend tous ses emails.
|
||||
*/
|
||||
public function suspendCustomer(Customer $customer, string $reason = 'Impaye'): bool
|
||||
{
|
||||
$previousState = $customer->getState();
|
||||
|
||||
if (Customer::STATE_SUSPENDED === $previousState) {
|
||||
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, 'Client deja suspendu', 'warning', true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer,
|
||||
'Debut suspension client '.$customer->getFullName().' ('.$customer->getEmail().') - Raison: '.$reason,
|
||||
'critical', true, $previousState, Customer::STATE_SUSPENDED
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. Suspend le compte client
|
||||
$customer->setState(Customer::STATE_SUSPENDED);
|
||||
$this->em->flush();
|
||||
|
||||
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer,
|
||||
'Compte client passe en suspended', 'info', true, $previousState, Customer::STATE_SUSPENDED
|
||||
);
|
||||
|
||||
// 2. Suspend tous les sites
|
||||
$websites = $this->em->getRepository(Website::class)->findBy(['customer' => $customer]);
|
||||
foreach ($websites as $website) {
|
||||
$this->suspendWebsite($website, $customer, $reason);
|
||||
}
|
||||
|
||||
// 3. Suspend tous les emails
|
||||
$domains = $this->em->getRepository(Domain::class)->findBy(['customer' => $customer]);
|
||||
foreach ($domains as $domain) {
|
||||
$emails = $this->em->getRepository(DomainEmail::class)->findBy(['domain' => $domain]);
|
||||
foreach ($emails as $email) {
|
||||
$this->suspendDomainEmail($email, $customer, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer,
|
||||
'Suspension complete - Sites: '.\count($websites).' - Domaines: '.\count($domains),
|
||||
'critical', true
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logError(ActionLog::ACTION_SUSPEND_CUSTOMER, $customer, 'Erreur suspension client', $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retablit un client suspendu : reactive le compte + reactive les sites + reactive les emails.
|
||||
*/
|
||||
public function unsuspendCustomer(Customer $customer, string $reason = 'Paiement recu'): bool
|
||||
{
|
||||
$previousState = $customer->getState();
|
||||
|
||||
if (Customer::STATE_SUSPENDED !== $previousState) {
|
||||
$this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, 'Client non suspendu, rien a faire', 'warning', true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer,
|
||||
'Debut retablissement client '.$customer->getFullName().' - Raison: '.$reason,
|
||||
'info', true, $previousState, Customer::STATE_ACTIVE
|
||||
);
|
||||
|
||||
try {
|
||||
$customer->setState(Customer::STATE_ACTIVE);
|
||||
$this->em->flush();
|
||||
|
||||
// Reactive les sites
|
||||
$websites = $this->em->getRepository(Website::class)->findBy(['customer' => $customer, 'state' => Website::STATE_SUSPENDED]);
|
||||
foreach ($websites as $website) {
|
||||
$this->unsuspendWebsite($website, $customer, $reason);
|
||||
}
|
||||
|
||||
// Reactive les emails
|
||||
$domains = $this->em->getRepository(Domain::class)->findBy(['customer' => $customer]);
|
||||
foreach ($domains as $domain) {
|
||||
$emails = $this->em->getRepository(DomainEmail::class)->findBy(['domain' => $domain, 'state' => 'suspended']);
|
||||
foreach ($emails as $email) {
|
||||
$this->unsuspendDomainEmail($email, $customer, $reason);
|
||||
}
|
||||
}
|
||||
|
||||
$this->log(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer,
|
||||
'Retablissement complet', 'info', true
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logError(ActionLog::ACTION_UNSUSPEND_CUSTOMER, $customer, 'Erreur retablissement client', $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Suspension site ────────
|
||||
|
||||
public function suspendWebsite(Website $website, Customer $customer, string $reason = 'Impaye'): void
|
||||
{
|
||||
$prev = $website->getState();
|
||||
if (Website::STATE_SUSPENDED === $prev) {
|
||||
return;
|
||||
}
|
||||
|
||||
$website->setState(Website::STATE_SUSPENDED);
|
||||
$this->em->flush();
|
||||
|
||||
$this->log(ActionLog::ACTION_SUSPEND_WEBSITE, $customer,
|
||||
'Site '.$website->getName().' (UUID '.$website->getUuid().') suspendu - Raison: '.$reason,
|
||||
'critical', true, $prev, Website::STATE_SUSPENDED,
|
||||
$website->getId(), 'Website'
|
||||
);
|
||||
}
|
||||
|
||||
public function unsuspendWebsite(Website $website, Customer $customer, string $reason = 'Paiement recu'): void
|
||||
{
|
||||
$prev = $website->getState();
|
||||
$website->setState(Website::STATE_OPEN);
|
||||
$this->em->flush();
|
||||
|
||||
$this->log(ActionLog::ACTION_UNSUSPEND_WEBSITE, $customer,
|
||||
'Site '.$website->getName().' retabli - Raison: '.$reason,
|
||||
'info', true, $prev, Website::STATE_OPEN,
|
||||
$website->getId(), 'Website'
|
||||
);
|
||||
}
|
||||
|
||||
// ──────── Suspension email ────────
|
||||
|
||||
public function suspendDomainEmail(DomainEmail $email, Customer $customer, string $reason = 'Impaye'): void
|
||||
{
|
||||
$prev = $email->getState();
|
||||
if ('suspended' === $prev) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email->setState('suspended');
|
||||
$this->em->flush();
|
||||
|
||||
$this->log(ActionLog::ACTION_SUSPEND_DOMAIN_EMAIL, $customer,
|
||||
'Email '.$email->getFullEmail().' suspendu - Raison: '.$reason,
|
||||
'critical', true, $prev, 'suspended',
|
||||
$email->getId(), 'DomainEmail'
|
||||
);
|
||||
}
|
||||
|
||||
public function unsuspendDomainEmail(DomainEmail $email, Customer $customer, string $reason = 'Paiement recu'): void
|
||||
{
|
||||
$prev = $email->getState();
|
||||
$email->setState('active');
|
||||
$this->em->flush();
|
||||
|
||||
$this->log(ActionLog::ACTION_UNSUSPEND_DOMAIN_EMAIL, $customer,
|
||||
'Email '.$email->getFullEmail().' retabli - Raison: '.$reason,
|
||||
'info', true, $prev, 'active',
|
||||
$email->getId(), 'DomainEmail'
|
||||
);
|
||||
}
|
||||
|
||||
// ──────── Desactivation + suppression donnees ────────
|
||||
|
||||
/**
|
||||
* Desactive definitivement un client (pre-suppression).
|
||||
*/
|
||||
public function disableCustomer(Customer $customer, string $reason = 'Resiliation pour impaye'): bool
|
||||
{
|
||||
$previousState = $customer->getState();
|
||||
|
||||
$this->log(ActionLog::ACTION_DISABLE_CUSTOMER, $customer,
|
||||
'Desactivation definitive '.$customer->getFullName().' - Raison: '.$reason,
|
||||
'critical', true, $previousState, Customer::STATE_DISABLED
|
||||
);
|
||||
|
||||
try {
|
||||
$customer->setState(Customer::STATE_DISABLED);
|
||||
$this->em->flush();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logError(ActionLog::ACTION_DISABLE_CUSTOMER, $customer, 'Erreur desactivation', $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un client pour suppression (pending_delete, sera traite par le cron CleanPendingDeleteCommand).
|
||||
*/
|
||||
public function markForDeletion(Customer $customer, string $reason = 'Resiliation contrat + recouvrement'): bool
|
||||
{
|
||||
$previousState = $customer->getState();
|
||||
|
||||
$this->log(ActionLog::ACTION_DELETE_CUSTOMER_DATA, $customer,
|
||||
'Marquage suppression client '.$customer->getFullName().' ('.$customer->getEmail().') - Raison: '.$reason.' - Toutes les donnees seront supprimees par le cron nocturne.',
|
||||
'critical', true, $previousState, Customer::STATE_PENDING_DELETE,
|
||||
);
|
||||
|
||||
try {
|
||||
$customer->setState(Customer::STATE_PENDING_DELETE);
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->critical('ActionService: client '.$customer->getEmail().' marque pour suppression - '.$reason);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logError(ActionLog::ACTION_DELETE_CUSTOMER_DATA, $customer, 'Erreur marquage suppression', $e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Logging ────────
|
||||
|
||||
private function log(
|
||||
string $action,
|
||||
Customer $customer,
|
||||
string $message,
|
||||
string $severity = 'info',
|
||||
bool $success = true,
|
||||
?string $previousState = null,
|
||||
?string $newState = null,
|
||||
?int $entityId = null,
|
||||
?string $entityType = null,
|
||||
): void {
|
||||
$log = new ActionLog($action, $message, $severity, $success);
|
||||
$log->setCustomer($customer);
|
||||
$log->setPreviousState($previousState);
|
||||
$log->setNewState($newState);
|
||||
|
||||
if (null !== $entityId) {
|
||||
$log->setEntityId($entityId);
|
||||
$log->setEntityType($entityType);
|
||||
}
|
||||
|
||||
$this->em->persist($log);
|
||||
$this->em->flush();
|
||||
|
||||
$logMethod = match ($severity) {
|
||||
'critical' => 'critical',
|
||||
'danger' => 'error',
|
||||
'warning' => 'warning',
|
||||
default => 'info',
|
||||
};
|
||||
|
||||
$this->logger->$logMethod('ActionService: '.$message, [
|
||||
'action' => $action,
|
||||
'customer_id' => $customer->getId(),
|
||||
'customer_email' => $customer->getEmail(),
|
||||
'previous_state' => $previousState,
|
||||
'new_state' => $newState,
|
||||
'entity_id' => $entityId,
|
||||
'entity_type' => $entityType,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logError(string $action, Customer $customer, string $message, \Throwable $e): void
|
||||
{
|
||||
$log = new ActionLog($action, $message, 'critical', false);
|
||||
$log->setCustomer($customer);
|
||||
$log->setErrorMessage($e->getMessage());
|
||||
$log->setContext(json_encode([
|
||||
'exception' => $e::class,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5),
|
||||
]));
|
||||
|
||||
$this->em->persist($log);
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->critical('ActionService ERROR: '.$message.': '.$e->getMessage(), [
|
||||
'action' => $action,
|
||||
'customer_id' => $customer->getId(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,21 @@ class AdvertService
|
||||
private OrderNumberService $orderNumberService,
|
||||
private EntityManagerInterface $em,
|
||||
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
|
||||
#[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false',
|
||||
#[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isTvaEnabled(): bool
|
||||
{
|
||||
return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled;
|
||||
}
|
||||
|
||||
public function getTvaRate(): float
|
||||
{
|
||||
return (float) $this->tvaRate;
|
||||
}
|
||||
|
||||
public function create(?Devis $devis = null): Advert
|
||||
{
|
||||
$orderNumber = null !== $devis
|
||||
@@ -38,4 +50,26 @@ class AdvertService
|
||||
{
|
||||
return $this->create($devis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les totaux selon la config TVA.
|
||||
*/
|
||||
public function computeTotals(string $totalHt): array
|
||||
{
|
||||
$ht = (float) $totalHt;
|
||||
|
||||
if ($this->isTvaEnabled()) {
|
||||
$tva = round($ht * $this->getTvaRate(), 2);
|
||||
$ttc = round($ht + $tva, 2);
|
||||
} else {
|
||||
$tva = 0.0;
|
||||
$ttc = $ht;
|
||||
}
|
||||
|
||||
return [
|
||||
'totalHt' => number_format($ht, 2, '.', ''),
|
||||
'totalTva' => number_format($tva, 2, '.', ''),
|
||||
'totalTtc' => number_format($ttc, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,21 @@ class DevisService
|
||||
private OrderNumberService $orderNumberService,
|
||||
private EntityManagerInterface $em,
|
||||
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
|
||||
#[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false',
|
||||
#[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isTvaEnabled(): bool
|
||||
{
|
||||
return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled;
|
||||
}
|
||||
|
||||
public function getTvaRate(): float
|
||||
{
|
||||
return (float) $this->tvaRate;
|
||||
}
|
||||
|
||||
public function create(): Devis
|
||||
{
|
||||
$orderNumber = $this->orderNumberService->generateAndUse();
|
||||
@@ -25,4 +37,26 @@ class DevisService
|
||||
|
||||
return $devis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les totaux du devis selon la config TVA.
|
||||
*/
|
||||
public function computeTotals(string $totalHt): array
|
||||
{
|
||||
$ht = (float) $totalHt;
|
||||
|
||||
if ($this->isTvaEnabled()) {
|
||||
$tva = round($ht * $this->getTvaRate(), 2);
|
||||
$ttc = round($ht + $tva, 2);
|
||||
} else {
|
||||
$tva = 0.0;
|
||||
$ttc = $ht;
|
||||
}
|
||||
|
||||
return [
|
||||
'totalHt' => number_format($ht, 2, '.', ''),
|
||||
'totalTva' => number_format($tva, 2, '.', ''),
|
||||
'totalTtc' => number_format($ttc, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ namespace App\Service;
|
||||
*/
|
||||
class DnsInfraHelper
|
||||
{
|
||||
public const DOMAINS = ['siteconseil.fr', 'esy-web.dev'];
|
||||
public const DOMAINS = ['e-cosplay.fr', 'esy-web.dev'];
|
||||
|
||||
public const EXPECTED_MX = [
|
||||
'siteconseil.fr' => 'mail.esy-web.dev',
|
||||
'e-cosplay.fr' => 'mail.esy-web.dev',
|
||||
'esy-web.dev' => 'mail.esy-web.dev',
|
||||
];
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ class DocuSealService
|
||||
$this->api = new Api($apiKey, rtrim($baseUrl, '/').'/api');
|
||||
}
|
||||
|
||||
public function getApi(): Api
|
||||
{
|
||||
return $this->api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie l'attestation a DocuSeal pour auto-signature en un seul appel.
|
||||
*/
|
||||
@@ -48,8 +53,8 @@ class DocuSealService
|
||||
],
|
||||
'submitters' => [
|
||||
[
|
||||
'email' => 'contact@siteconseil.fr',
|
||||
'name' => 'SARL SITECONSEIL',
|
||||
'email' => 'contact@e-cosplay.fr',
|
||||
'name' => 'Association E-Cosplay',
|
||||
'role' => 'First Party',
|
||||
'completed' => true,
|
||||
'send_email' => false,
|
||||
@@ -192,6 +197,37 @@ class DocuSealService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere les donnees completes d'un submitter.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getSubmitterData(int $submitterId): ?array
|
||||
{
|
||||
try {
|
||||
return $this->api->getSubmitter($submitterId);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive une submission dans DocuSeal (invalide tous les liens de signature).
|
||||
*/
|
||||
public function archiveSubmission(int $submissionId): bool
|
||||
{
|
||||
try {
|
||||
$this->api->archiveSubmission($submissionId);
|
||||
$this->logger->info('DocuSeal: submission '.$submissionId.' archivee');
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('DocuSeal: echec archive submission '.$submissionId.': '.$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Telecharge et sauvegarde via Vich le PDF signe et le certificat d'audit depuis DocuSeal.
|
||||
*/
|
||||
@@ -275,12 +311,72 @@ class DocuSealService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un PDF comptable a DocuSeal pour signature par un utilisateur.
|
||||
* Retourne l'id du submitter pour suivi.
|
||||
*/
|
||||
public function sendComptaForSignature(
|
||||
string $pdfContent,
|
||||
string $documentName,
|
||||
string $signerEmail,
|
||||
string $signerName,
|
||||
string $exportType,
|
||||
string $periodFrom,
|
||||
string $periodTo,
|
||||
?string $completedRedirectUrl = null,
|
||||
): ?int {
|
||||
try {
|
||||
$pdfBase64 = base64_encode($pdfContent);
|
||||
|
||||
$submitter = [
|
||||
'email' => $signerEmail,
|
||||
'name' => $signerName,
|
||||
'role' => 'First Party',
|
||||
'send_email' => false,
|
||||
'metadata' => [
|
||||
'doc_type' => 'comptabilite',
|
||||
'export_type' => $exportType,
|
||||
'period_from' => $periodFrom,
|
||||
'period_to' => $periodTo,
|
||||
],
|
||||
];
|
||||
|
||||
if (null !== $completedRedirectUrl) {
|
||||
$submitter['completed_redirect_url'] = $completedRedirectUrl;
|
||||
}
|
||||
|
||||
$result = $this->api->createSubmissionFromPdf([
|
||||
'name' => $documentName,
|
||||
'send_email' => false,
|
||||
'flatten' => true,
|
||||
'documents' => [
|
||||
[
|
||||
'name' => str_replace(' ', '_', $documentName).'.pdf',
|
||||
'file' => 'data:application/pdf;base64,'.$pdfBase64,
|
||||
],
|
||||
],
|
||||
'submitters' => [$submitter],
|
||||
]);
|
||||
|
||||
$this->logger->info('DocuSeal compta: reponse API', ['result' => $result]);
|
||||
|
||||
return $result['submitters'][0]['id'] ?? ($result[0]['id'] ?? null);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('DocuSeal compta: exception API: '.$e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'document' => $documentName,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getLogoBase64(): string
|
||||
{
|
||||
$logoPath = $this->projectDir.'/public/logo_facture.png';
|
||||
$logoPath = $this->projectDir.'/public/logo.jpg';
|
||||
|
||||
if (!file_exists($logoPath)) {
|
||||
return 'SARL SITECONSEIL';
|
||||
return 'Association E-Cosplay';
|
||||
}
|
||||
|
||||
return 'data:image/jpeg;base64,'.base64_encode(file_get_contents($logoPath));
|
||||
|
||||
@@ -347,7 +347,7 @@ class EsyMailService
|
||||
// ──── Vérification DNS ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Vérifie la config DNS Esy-Mail (réception) pour un domaine.
|
||||
* Vérifie la config DNS E-Mail (réception) pour un domaine.
|
||||
* MX → mail hostname, SPF includes, DKIM, DMARC.
|
||||
*
|
||||
* @return array{ok: bool, mx: bool, spf: bool, dkim: bool, dmarc: bool, details: array<string, string>}
|
||||
@@ -402,7 +402,7 @@ class EsyMailService
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la config DNS Esy-Mailer (envoi AWS SES) pour un domaine.
|
||||
* Vérifie la config DNS E-Mailer (envoi AWS SES) pour un domaine.
|
||||
* SES domaine vérifié, DKIM SES, SPF include:amazonses.com, MAIL FROM.
|
||||
*
|
||||
* @return array{ok: bool, ses_verified: bool, ses_dkim: bool, spf_ses: bool, mail_from: bool, details: array<string, string>}
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\AdvertLine;
|
||||
use App\Entity\Facture;
|
||||
use App\Entity\FactureLine;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
class FactureService
|
||||
@@ -12,10 +15,23 @@ class FactureService
|
||||
public function __construct(
|
||||
private OrderNumberService $orderNumberService,
|
||||
private EntityManagerInterface $em,
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
|
||||
#[Autowire(env: 'TVA_ENABLED')] private string $tvaEnabled = 'false',
|
||||
#[Autowire(env: 'TVA_RATE')] private string $tvaRate = '0.20',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isTvaEnabled(): bool
|
||||
{
|
||||
return 'true' === $this->tvaEnabled || '1' === $this->tvaEnabled;
|
||||
}
|
||||
|
||||
public function getTvaRate(): float
|
||||
{
|
||||
return (float) $this->tvaRate;
|
||||
}
|
||||
|
||||
public function create(?Advert $advert = null): Facture
|
||||
{
|
||||
if (null !== $advert) {
|
||||
@@ -37,6 +53,12 @@ class FactureService
|
||||
|
||||
$facture = new Facture($advert->getOrderNumber(), $this->hmacSecret);
|
||||
$facture->setAdvert($advert);
|
||||
$facture->setCustomer($advert->getCustomer());
|
||||
|
||||
// Copie des totaux
|
||||
$facture->setTotalHt($advert->getTotalHt());
|
||||
$facture->setTotalTva($advert->getTotalTva());
|
||||
$facture->setTotalTtc($advert->getTotalTtc());
|
||||
|
||||
if ($existingCount > 0) {
|
||||
$facture->setSplitIndex($existingCount + 1);
|
||||
@@ -47,9 +69,87 @@ class FactureService
|
||||
}
|
||||
}
|
||||
|
||||
// Copie des lignes depuis l'avis
|
||||
foreach ($advert->getLines() as $advertLine) {
|
||||
$line = new FactureLine(
|
||||
$facture,
|
||||
$advertLine->getTitle(),
|
||||
$advertLine->getPriceHt(),
|
||||
$advertLine->getPos(),
|
||||
);
|
||||
if (null !== $advertLine->getDescription()) {
|
||||
$line->setDescription($advertLine->getDescription());
|
||||
}
|
||||
if (null !== $advertLine->getType()) {
|
||||
$line->setType($advertLine->getType());
|
||||
}
|
||||
if (null !== $advertLine->getServiceId()) {
|
||||
$line->setServiceId($advertLine->getServiceId());
|
||||
}
|
||||
$this->em->persist($line);
|
||||
$facture->addLine($line);
|
||||
}
|
||||
|
||||
$this->em->persist($facture);
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->info('FactureService: facture '.$facture->getInvoiceNumber().' creee depuis advert '.$advert->getOrderNumber()->getNumOrder());
|
||||
|
||||
return $facture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une facture depuis un avis dont le paiement est complet.
|
||||
* Marque la facture comme payee avec la date et la methode.
|
||||
*/
|
||||
public function createPaidFactureFromAdvert(Advert $advert, string $amount, string $methodLabel): ?Facture
|
||||
{
|
||||
$advertTotal = (float) $advert->getTotalTtc();
|
||||
$paidAmount = (float) $amount;
|
||||
|
||||
// Ne generer la facture que si le montant paye == montant de l'avis
|
||||
if (abs($advertTotal - $paidAmount) > 0.01) {
|
||||
$this->logger->warning('FactureService: montant paye ('.$amount.') != montant avis ('.$advert->getTotalTtc().') - facture non generee', [
|
||||
'advert' => $advert->getOrderNumber()->getNumOrder(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$facture = $this->createFromAdvert($advert);
|
||||
|
||||
// Marquer comme payee
|
||||
$facture->setIsPaid(true);
|
||||
$facture->setPaidAt(new \DateTimeImmutable());
|
||||
$facture->setPaidMethod($methodLabel);
|
||||
$facture->setState(Facture::STATE_PAID);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$this->logger->info('FactureService: facture '.$facture->getInvoiceNumber().' marquee payee ('.$methodLabel.', '.$amount.' EUR)');
|
||||
|
||||
return $facture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les totaux selon la config TVA.
|
||||
*/
|
||||
public function computeTotals(string $totalHt): array
|
||||
{
|
||||
$ht = (float) $totalHt;
|
||||
|
||||
if ($this->isTvaEnabled()) {
|
||||
$tva = round($ht * $this->getTvaRate(), 2);
|
||||
$ttc = round($ht + $tva, 2);
|
||||
} else {
|
||||
$tva = 0.0;
|
||||
$ttc = $ht;
|
||||
}
|
||||
|
||||
return [
|
||||
'totalHt' => number_format($ht, 2, '.', ''),
|
||||
'totalTva' => number_format($tva, 2, '.', ''),
|
||||
'totalTtc' => number_format($ttc, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
282
src/Service/GoogleSearchService.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Wrapper API Google Search Console, Indexing API et URL Inspection API.
|
||||
* Auth via Service Account (JWT OAuth2 RS256).
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class GoogleSearchService
|
||||
{
|
||||
private const SEARCH_CONSOLE_API = 'https://www.googleapis.com/webmasters/v3';
|
||||
private const INDEXING_API = 'https://indexing.googleapis.com/v3';
|
||||
private const INSPECTION_API = 'https://searchconsole.googleapis.com/v1';
|
||||
private const TOKEN_URI = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
private const SCOPES = [
|
||||
'https://www.googleapis.com/auth/webmasters',
|
||||
'https://www.googleapis.com/auth/indexing',
|
||||
];
|
||||
|
||||
private ?string $accessToken = null;
|
||||
private ?int $tokenExpiresAt = null;
|
||||
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire(env: 'GOOGLE_SEARCH_CONSOLE_KEY')] private string $serviceAccountJson = '',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return '' !== $this->serviceAccountJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un site dans Google Search Console.
|
||||
*/
|
||||
public function addSite(string $url): bool
|
||||
{
|
||||
return null !== $this->request('PUT', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie la propriete d'un site.
|
||||
*/
|
||||
public function verifySite(string $url): bool
|
||||
{
|
||||
return null !== $this->request('POST', 'https://www.googleapis.com/siteVerification/v1/webResource?verificationMethod=FILE', [
|
||||
'site' => ['type' => 'SITE', 'identifier' => $url],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un site de Search Console.
|
||||
*/
|
||||
public function removeSite(string $url): bool
|
||||
{
|
||||
return null !== $this->request('DELETE', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Soumet un sitemap.
|
||||
*/
|
||||
public function submitSitemap(string $siteUrl, string $sitemapUrl): bool
|
||||
{
|
||||
return null !== $this->request('PUT', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/sitemaps/'.urlencode($sitemapUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les sitemaps d'un site.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listSitemaps(string $siteUrl): array
|
||||
{
|
||||
$result = $this->request('GET', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/sitemaps');
|
||||
|
||||
return $result['sitemap'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie le statut d'indexation d'une URL via URL Inspection API.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getIndexingStatus(string $url): ?array
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
$siteUrl = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? '').'/';
|
||||
|
||||
return $this->request('POST', self::INSPECTION_API.'/urlInspection/index:inspect', [
|
||||
'inspectionUrl' => $url,
|
||||
'siteUrl' => $siteUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande l'indexation d'une URL via Indexing API.
|
||||
*/
|
||||
public function submitUrlForIndexing(string $url): bool
|
||||
{
|
||||
return null !== $this->request('POST', self::INDEXING_API.'/urlNotifications:publish', [
|
||||
'url' => $url,
|
||||
'type' => 'URL_UPDATED',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere les donnees de performance (Search Analytics).
|
||||
*
|
||||
* @return array{clicks: int, impressions: int, ctr: float, position: float, rows: list<array<string, mixed>>}|null
|
||||
*/
|
||||
public function getPerformanceData(string $siteUrl, string $startDate, string $endDate): ?array
|
||||
{
|
||||
$result = $this->request('POST', self::SEARCH_CONSOLE_API.'/sites/'.urlencode($siteUrl).'/searchAnalytics/query', [
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'dimensions' => ['query', 'page'],
|
||||
'rowLimit' => 100,
|
||||
]);
|
||||
|
||||
if (null === $result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totals = ['clicks' => 0, 'impressions' => 0, 'ctr' => 0.0, 'position' => 0.0];
|
||||
$rows = $result['rows'] ?? [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$totals['clicks'] += $row['clicks'] ?? 0;
|
||||
$totals['impressions'] += $row['impressions'] ?? 0;
|
||||
}
|
||||
|
||||
if (\count($rows) > 0) {
|
||||
$totals['ctr'] = $totals['impressions'] > 0 ? $totals['clicks'] / $totals['impressions'] : 0.0;
|
||||
$totals['position'] = array_sum(array_column($rows, 'position')) / \count($rows);
|
||||
}
|
||||
|
||||
return [
|
||||
'clicks' => $totals['clicks'],
|
||||
'impressions' => $totals['impressions'],
|
||||
'ctr' => round($totals['ctr'], 4),
|
||||
'position' => round($totals['position'], 1),
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere les erreurs de crawl via URL Inspection (le endpoint crawlErrors est deprecie).
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function getCrawlErrors(string $siteUrl): array
|
||||
{
|
||||
// L'ancien endpoint urlCrawlErrorsCounts est deprecie.
|
||||
// On utilise l'URL Inspection API sur les pages du sitemap comme fallback.
|
||||
$this->logger->info('GoogleSearchService::getCrawlErrors: utilise URL Inspection API (endpoint crawlErrors deprecie)');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ──────── Auth OAuth2 JWT ────────
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function request(string $method, string $url, ?array $body = null): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
$this->logger->warning('GoogleSearchService: service account non configure');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->getAccessToken();
|
||||
|
||||
$options = [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
if (null !== $body) {
|
||||
$options['json'] = $body;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request($method, $url, $options);
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 400) {
|
||||
$this->logger->error('GoogleSearchService: HTTP '.$statusCode.' '.$method.' '.$url, [
|
||||
'body' => $response->getContent(false),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (204 === $statusCode || '' === $response->getContent(false)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('GoogleSearchService: '.$e->getMessage(), [
|
||||
'method' => $method,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
if (null !== $this->accessToken && null !== $this->tokenExpiresAt && time() < $this->tokenExpiresAt) {
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
$serviceAccount = json_decode($this->serviceAccountJson, true);
|
||||
if (!\is_array($serviceAccount)) {
|
||||
throw new \RuntimeException('GOOGLE_SEARCH_CONSOLE_KEY JSON invalide');
|
||||
}
|
||||
|
||||
$jwt = $this->createJwt($serviceAccount, self::SCOPES);
|
||||
|
||||
$response = $this->httpClient->request('POST', self::TOKEN_URI, [
|
||||
'body' => [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
$this->accessToken = $data['access_token'];
|
||||
$this->tokenExpiresAt = time() + ($data['expires_in'] ?? 3600) - 60;
|
||||
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $serviceAccount
|
||||
* @param list<string> $scopes
|
||||
*/
|
||||
private function createJwt(array $serviceAccount, array $scopes): string
|
||||
{
|
||||
$now = time();
|
||||
|
||||
$header = $this->base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
|
||||
|
||||
$claims = $this->base64UrlEncode(json_encode([
|
||||
'iss' => $serviceAccount['client_email'],
|
||||
'scope' => implode(' ', $scopes),
|
||||
'aud' => self::TOKEN_URI,
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
]));
|
||||
|
||||
$payload = $header.'.'.$claims;
|
||||
|
||||
$privateKey = $serviceAccount['private_key'];
|
||||
$signature = '';
|
||||
if (!openssl_sign($payload, $signature, $privateKey, \OPENSSL_ALGO_SHA256)) {
|
||||
throw new \RuntimeException('Erreur signature JWT (openssl_sign)');
|
||||
}
|
||||
|
||||
return $payload.'.'.$this->base64UrlEncode($signature);
|
||||
}
|
||||
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -11,23 +11,18 @@ class KeycloakAdminService
|
||||
private const PATH_GROUPS = '/groups';
|
||||
private const AUTH_BEARER = 'Bearer ';
|
||||
|
||||
/** Groupes requis pour le CRM SITECONSEIL */
|
||||
/** Groupes requis pour le CRM E-Cosplay */
|
||||
private const REQUIRED_GROUPS = [
|
||||
'siteconseil_admin',
|
||||
'siteconseil_member',
|
||||
'esy-web',
|
||||
'esy-mail',
|
||||
'esy-mailer',
|
||||
'esy-analytics',
|
||||
'esy-monitor',
|
||||
'esy-defender',
|
||||
'esy-translate',
|
||||
'esy-signature',
|
||||
'esy-creator',
|
||||
'esy-aide',
|
||||
'esy-meet',
|
||||
'esy-tchat',
|
||||
'esy-ndd',
|
||||
'superadmin',
|
||||
'super_admin_asso',
|
||||
'gp_asso',
|
||||
'gp_contest',
|
||||
'gp_mail',
|
||||
'gp_mailling',
|
||||
'gp_member',
|
||||
'gp_ndd',
|
||||
'gp_sign',
|
||||
'gp_ticket',
|
||||
];
|
||||
|
||||
private ?string $accessToken = null;
|
||||
|
||||
@@ -31,7 +31,7 @@ class MailerService
|
||||
|
||||
public function getAdminFrom(): string
|
||||
{
|
||||
return 'SARL SITECONSEIL <'.$this->adminEmail.'>';
|
||||
return 'Association E-Cosplay <'.$this->adminEmail.'>';
|
||||
}
|
||||
|
||||
public function send(Email $email): void
|
||||
@@ -92,7 +92,7 @@ class MailerService
|
||||
}
|
||||
|
||||
$messageId = bin2hex(random_bytes(16));
|
||||
$email->getHeaders()->addIdHeader('Message-ID', $messageId.'@siteconseil.fr');
|
||||
$email->getHeaders()->addIdHeader('Message-ID', $messageId.'@e-cosplay.fr');
|
||||
|
||||
$trackingUrl = $this->urlGenerator->generate('app_email_track', [
|
||||
'messageId' => $messageId,
|
||||
@@ -103,7 +103,7 @@ class MailerService
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$html = $email->getHtmlBody();
|
||||
$html = str_replace('https://crm.siteconseil.fr/logo_facture.png', $trackingUrl, $html);
|
||||
$html = str_replace('https://crm.e-cosplay.fr/logo.jpg', $trackingUrl, $html);
|
||||
$html = str_replace('__VIEW_URL__', $viewUrl, $html);
|
||||
|
||||
$dnsReportUrl = $this->urlGenerator->generate('app_dns_report', [
|
||||
@@ -122,10 +122,10 @@ class MailerService
|
||||
$this->em->persist($tracking);
|
||||
$this->em->flush();
|
||||
|
||||
// Ajout automatique du fichier VCF (fiche contact SITECONSEIL)
|
||||
// Ajout automatique du fichier VCF (fiche contact E-Cosplay)
|
||||
$vcfPath = $this->generateVcf();
|
||||
if (null !== $vcfPath) {
|
||||
$email->attachFromPath($vcfPath, 'SARL-SITECONSEIL.vcf', 'text/vcard');
|
||||
$email->attachFromPath($vcfPath, 'Association-E-Cosplay.vcf', 'text/vcard');
|
||||
}
|
||||
|
||||
if ($canUnsubscribe) {
|
||||
@@ -208,24 +208,24 @@ class MailerService
|
||||
}
|
||||
|
||||
/**
|
||||
* Genere un fichier VCF (vCard 3.0) pour la fiche contact SARL SITECONSEIL.
|
||||
* Genere un fichier VCF (vCard 3.0) pour la fiche contact Association E-Cosplay.
|
||||
*/
|
||||
private function generateVcf(): ?string
|
||||
{
|
||||
$vcf = implode("\r\n", [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'N:SITECONSEIL;SARL;;;',
|
||||
'FN:SARL SITECONSEIL',
|
||||
'ORG:SARL SITECONSEIL',
|
||||
'TEL;TYPE=WORK,VOICE:+33323056243',
|
||||
'EMAIL;TYPE=INTERNET,PREF:contact@siteconseil.fr',
|
||||
'EMAIL;TYPE=INTERNET:s.com@siteconseil.fr',
|
||||
'ADR;TYPE=WORK:;;27 rue Le Serurier;Saint-Quentin;;02100;France',
|
||||
'URL:https://www.siteconseil.fr',
|
||||
'URL:https://crm.siteconseil.fr',
|
||||
'NOTE:SIREN 943121517 - SIRET 418 664 058 00025 - APE 6201Z',
|
||||
'CATEGORIES:Prestataire,IT,Web',
|
||||
'N:E-Cosplay;Association;;;',
|
||||
'FN:Association E-Cosplay',
|
||||
'ORG:Association E-Cosplay',
|
||||
'TEL;TYPE=WORK,VOICE:+33679348802',
|
||||
'EMAIL;TYPE=INTERNET,PREF:contact@e-cosplay.fr',
|
||||
'EMAIL;TYPE=INTERNET:contact@e-cosplay.fr',
|
||||
'ADR;TYPE=WORK:;;42 rue de Saint-Quentin;Beautor;;02800;France',
|
||||
'URL:https://www.e-cosplay.fr',
|
||||
'URL:https://crm.e-cosplay.fr',
|
||||
'NOTE:SIREN 943121517 - SIRET 943 121 517 00011 - APE 9329Z',
|
||||
'CATEGORIES:Association,Cosplay,Evenementiel',
|
||||
'REV:'.date('Ymd\THis\Z'),
|
||||
'END:VCARD',
|
||||
]);
|
||||
@@ -263,7 +263,7 @@ class MailerService
|
||||
|
||||
$email->getHeaders()->addTextHeader(
|
||||
'List-Unsubscribe',
|
||||
sprintf('<%s>, <mailto:unsubscribe@siteconseil.fr?subject=unsubscribe-%s>', $unsubscribeUrl, urlencode($to))
|
||||
sprintf('<%s>, <mailto:unsubscribe@e-cosplay.fr?subject=unsubscribe-%s>', $unsubscribeUrl, urlencode($to))
|
||||
);
|
||||
$email->getHeaders()->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Service;
|
||||
|
||||
use App\Entity\Advert;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Facture;
|
||||
use App\Entity\CustomerContact;
|
||||
use App\Entity\Devis;
|
||||
use App\Entity\Domain;
|
||||
@@ -300,6 +301,65 @@ class MeilisearchService
|
||||
}
|
||||
}
|
||||
|
||||
public function indexFacture(Facture $facture): void
|
||||
{
|
||||
try {
|
||||
$this->client->index('customer_facture')->addDocuments([$this->serializeFacture($facture)]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Meilisearch: Failed to index facture '.$facture->getId().': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function removeFacture(int $factureId): void
|
||||
{
|
||||
try {
|
||||
$this->client->index('customer_facture')->deleteDocument($factureId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Meilisearch: Failed to remove facture '.$factureId.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function searchFactures(string $query, int $limit = 20, ?int $customerId = null): array
|
||||
{
|
||||
try {
|
||||
$options = ['limit' => $limit];
|
||||
if (null !== $customerId) {
|
||||
$options['filter'] = 'customerId = '.$customerId;
|
||||
}
|
||||
|
||||
return $this->client->index('customer_facture')->search($query, $options)->getHits();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Meilisearch: search factures error: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function purgeAllIndexes(): void
|
||||
{
|
||||
$indexes = ['customer', 'reseller', 'price_auto', 'customer_contact', 'customer_ndd', 'customer_website', 'customer_devis', 'customer_advert', 'customer_facture'];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
try {
|
||||
$this->client->index($index)->deleteAllDocuments();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Meilisearch: purge index '.$index.' failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getIndexCount(string $indexName): int
|
||||
{
|
||||
try {
|
||||
$stats = $this->client->index($indexName)->stats();
|
||||
|
||||
return $stats['numberOfDocuments'] ?? 0;
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function setupIndexes(): void
|
||||
{
|
||||
try {
|
||||
@@ -397,6 +457,18 @@ class MeilisearchService
|
||||
$this->client->index('customer_advert')->updateFilterableAttributes([
|
||||
'customerId', 'state',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->client->createIndex('customer_facture', ['primaryKey' => 'id']);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Meilisearch: setupIndexes (customer_facture) - '.$e->getMessage());
|
||||
}
|
||||
$this->client->index('customer_facture')->updateSearchableAttributes([
|
||||
'invoiceNumber', 'customerName', 'customerEmail', 'state',
|
||||
]);
|
||||
$this->client->index('customer_facture')->updateFilterableAttributes([
|
||||
'customerId', 'state', 'isPaid',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -537,6 +609,26 @@ class MeilisearchService
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function serializeFacture(Facture $facture): array
|
||||
{
|
||||
$customer = $facture->getCustomer();
|
||||
|
||||
return [
|
||||
'id' => $facture->getId(),
|
||||
'invoiceNumber' => $facture->getInvoiceNumber(),
|
||||
'state' => $facture->getState(),
|
||||
'totalHt' => $facture->getTotalHt(),
|
||||
'totalTtc' => $facture->getTotalTtc(),
|
||||
'isPaid' => $facture->isPaid(),
|
||||
'paidMethod' => $facture->getPaidMethod(),
|
||||
'customerId' => $customer?->getId(),
|
||||
'customerName' => $customer?->getFullName(),
|
||||
'customerEmail' => $customer?->getEmail(),
|
||||
'createdAt' => $facture->getCreatedAt()->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function serializeAdvert(Advert $advert): array
|
||||
{
|
||||
|
||||
@@ -19,12 +19,17 @@ class AdvertPdf extends Fpdi
|
||||
private array $items = [];
|
||||
|
||||
private string $qrBase64 = '';
|
||||
private bool $skipHeaderFooter = false;
|
||||
private int $lastAdvertPage = 0;
|
||||
private ?\Twig\Environment $twig = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly Advert $advert,
|
||||
?UrlGeneratorInterface $urlGenerator = null,
|
||||
?\Twig\Environment $twig = null,
|
||||
) {
|
||||
$this->twig = $twig;
|
||||
parent::__construct();
|
||||
|
||||
// Generation QR code vers la page de paiement
|
||||
@@ -58,10 +63,14 @@ class AdvertPdf extends Fpdi
|
||||
|
||||
public function Header(): void
|
||||
{
|
||||
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo_facture.png';
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
|
||||
if (file_exists($logo)) {
|
||||
$this->Image($logo, 65, 5, 80);
|
||||
$this->Image($logo, 10, 8, 53);
|
||||
}
|
||||
|
||||
$formatter = new \IntlDateFormatter(
|
||||
@@ -73,7 +82,7 @@ class AdvertPdf extends Fpdi
|
||||
);
|
||||
|
||||
$numText = $this->enc('AVIS DE PAIEMENT N° '.$this->advert->getOrderNumber()->getNumOrder());
|
||||
$dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->advert->getCreatedAt()));
|
||||
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->advert->getCreatedAt()));
|
||||
|
||||
$this->Text(15, 80, $numText);
|
||||
$this->Text(15, 85, $dateText);
|
||||
@@ -156,6 +165,62 @@ class AdvertPdf extends Fpdi
|
||||
|
||||
$this->displaySummary();
|
||||
$this->displayQrCode();
|
||||
$this->appendCgv();
|
||||
|
||||
}
|
||||
|
||||
private function appendCgv(): void
|
||||
{
|
||||
if (null === $this->twig) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$html = $this->twig->render('pdf/cgv.html.twig');
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
|
||||
file_put_contents($tmpCgv, $dompdf->output());
|
||||
|
||||
$this->lastAdvertPage = $this->PageNo();
|
||||
$this->skipHeaderFooter = true;
|
||||
$pageCount = $this->setSourceFile($tmpCgv);
|
||||
|
||||
for ($i = 1; $i <= $pageCount; ++$i) {
|
||||
$tpl = $this->importPage($i);
|
||||
$size = $this->getTemplateSize($tpl);
|
||||
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
|
||||
$this->useTemplate($tpl);
|
||||
}
|
||||
|
||||
@unlink($tmpCgv);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function appendRib(): void
|
||||
{
|
||||
$ribPath = $this->kernel->getProjectDir().'/public/rib.pdf';
|
||||
if (!file_exists($ribPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->lastAdvertPage = $this->PageNo();
|
||||
$this->skipHeaderFooter = true;
|
||||
$pageCount = $this->setSourceFile($ribPath);
|
||||
|
||||
for ($i = 1; $i <= $pageCount; ++$i) {
|
||||
$tpl = $this->importPage($i);
|
||||
$size = $this->getTemplateSize($tpl);
|
||||
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
|
||||
$this->useTemplate($tpl);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function displayQrCode(): void
|
||||
@@ -165,7 +230,7 @@ class AdvertPdf extends Fpdi
|
||||
}
|
||||
|
||||
$this->SetAutoPageBreak(false);
|
||||
$y = $this->GetPageHeight() - 55;
|
||||
$y = $this->GetPageHeight() - 80;
|
||||
|
||||
// QR code en bas a gauche
|
||||
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png';
|
||||
@@ -175,52 +240,65 @@ class AdvertPdf extends Fpdi
|
||||
$this->Image($tmpQr, 15, $y, 30, 30);
|
||||
@unlink($tmpQr);
|
||||
|
||||
// Texte a droite du QR
|
||||
$this->SetXY(50, $y + 5);
|
||||
// Texte sous le QR code
|
||||
$this->SetXY(15, $y + 32);
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(100, 5, $this->enc('Scannez pour payer'), 0, 1, 'L');
|
||||
$this->SetX(50);
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->Cell(50, 5, $this->enc('Scannez pour payer'), 0, 1, 'L');
|
||||
$this->SetX(15);
|
||||
$this->SetFont('Arial', '', 7);
|
||||
$this->SetTextColor(100, 100, 100);
|
||||
$this->Cell(100, 5, $this->enc('Flashez ce QR code pour acceder aux options de paiement.'), 0, 1, 'L');
|
||||
$this->Cell(60, 4, $this->enc('Flashez ce QR code pour acceder'), 0, 1, 'L');
|
||||
$this->SetX(15);
|
||||
$this->Cell(60, 4, $this->enc('aux options de paiement.'), 0, 1, 'L');
|
||||
}
|
||||
|
||||
private function displaySummary(): void
|
||||
{
|
||||
$totalHt = array_sum(array_column($this->items, 'priceHt'));
|
||||
$totalTva = $totalHt * 0.20;
|
||||
$totalTtc = $totalHt + $totalTva;
|
||||
$totalHt = (float) $this->advert->getTotalHt();
|
||||
$totalTva = (float) $this->advert->getTotalTva();
|
||||
$totalTtc = (float) $this->advert->getTotalTtc();
|
||||
$tvaEnabled = $totalTva > 0.01;
|
||||
|
||||
$this->SetY(-60);
|
||||
|
||||
$this->SetFont('Arial', '', 12);
|
||||
$this->Cell(100, 10, $this->enc('Total HT :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
$this->Cell(135, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
|
||||
$this->Cell(35, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetY($tvaEnabled ? -60 : -50);
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->Cell(135, 10, $this->enc('Total TTC :'), 0, 0, 'R');
|
||||
$this->Cell(35, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
if ($tvaEnabled) {
|
||||
$this->SetFont('Arial', '', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
} else {
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetX(105);
|
||||
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
|
||||
}
|
||||
}
|
||||
|
||||
public function Footer(): void
|
||||
{
|
||||
$this->SetY(-32);
|
||||
$this->Ln(10);
|
||||
$this->SetFont('Arial', 'B', 8);
|
||||
$this->SetTextColor(253, 140, 4);
|
||||
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastAdvertPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetY(-28);
|
||||
$this->SetDrawColor(253, 140, 4);
|
||||
$this->Cell(190, 5, $this->enc('Partenaire de vos projects de puis 1997'), 0, 1, 'C');
|
||||
$this->Line(15, $this->GetY(), 195, $this->GetY());
|
||||
$this->Ln(4);
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Ln(2);
|
||||
$this->Cell(190, 4, $this->enc('27, rue le Sérurier - 02100 SAINT-QUENTIN - Tél: 03 23 05 62 43'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('e-mail : s.com@siteconseil.fr - www.siteconseil.fr'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('S.A.R.L aux captial de 71400 ').EURO.' - '.$this->enc('N°SIRET 418 664 058 00025 - N° TVA FR 05 418 664 058 - CODE APE 6201 Z - R.C. St-Quentin 418 664 058'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
|
||||
|
||||
$this->SetFont('Arial', 'I', 7);
|
||||
$this->SetTextColor(150, 150, 150);
|
||||
|
||||
409
src/Service/Pdf/ComptaPdf.php
Normal file
@@ -0,0 +1,409 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Pdf;
|
||||
|
||||
use setasign\Fpdi\Fpdi;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
if (!\defined('EURO')) {
|
||||
\define('EURO', \chr(128));
|
||||
}
|
||||
|
||||
class ComptaPdf extends Fpdi
|
||||
{
|
||||
private string $documentTitle;
|
||||
private string $periodFrom;
|
||||
private string $periodTo;
|
||||
|
||||
/** @var list<array<string, string>> */
|
||||
private array $rows = [];
|
||||
|
||||
/** @var list<string> */
|
||||
private array $columns = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
private array $columnWidths = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
string $documentTitle,
|
||||
string $periodFrom,
|
||||
string $periodTo,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->documentTitle = $documentTitle;
|
||||
$this->periodFrom = $periodFrom;
|
||||
$this->periodTo = $periodTo;
|
||||
|
||||
$this->SetTitle($this->enc($documentTitle));
|
||||
$this->SetAuthor($this->enc('Association E-Cosplay'));
|
||||
$this->SetCreator('CRM E-Cosplay');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, string>> $rows
|
||||
*/
|
||||
public function setData(array $rows): void
|
||||
{
|
||||
$this->rows = $rows;
|
||||
if (!empty($rows)) {
|
||||
$this->columns = array_keys($rows[0]);
|
||||
$this->columnWidths = $this->computeColumnWidths();
|
||||
}
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$this->AliasNbPages();
|
||||
$this->AddPage('L');
|
||||
|
||||
$this->writeContextBlock();
|
||||
$this->writeDataTable();
|
||||
$this->writeSummary();
|
||||
$this->writeSignatureBlock();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Header / Footer
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function Header(): void
|
||||
{
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
|
||||
if (file_exists($logo)) {
|
||||
$this->Image($logo, 10, 8, 40);
|
||||
}
|
||||
|
||||
// Titre du document
|
||||
$this->SetFont('Arial', 'B', 16);
|
||||
$this->SetXY(60, 10);
|
||||
$this->Cell(0, 8, $this->enc(mb_strtoupper($this->documentTitle)), 0, 1, 'L');
|
||||
|
||||
// Periode
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$this->SetXY(60, 18);
|
||||
$this->Cell(0, 5, $this->enc('Periode : du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L');
|
||||
|
||||
// Date de generation
|
||||
$formatter = new \IntlDateFormatter(
|
||||
'fr_FR',
|
||||
\IntlDateFormatter::FULL,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Europe/Paris',
|
||||
\IntlDateFormatter::GREGORIAN
|
||||
);
|
||||
$this->SetXY(60, 23);
|
||||
$this->SetFont('Arial', '', 9);
|
||||
$this->SetTextColor(100, 100, 100);
|
||||
$this->Cell(0, 5, $this->enc('Genere le '.$formatter->format(new \DateTime())), 0, 1, 'L');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->Ln(5);
|
||||
}
|
||||
|
||||
public function Footer(): void
|
||||
{
|
||||
$this->SetY(-20);
|
||||
$this->SetDrawColor(253, 140, 4);
|
||||
$this->Line(15, $this->GetY(), $this->GetPageWidth() - 15, $this->GetY());
|
||||
$this->Ln(3);
|
||||
$this->SetFont('Arial', '', 7);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(0, 3, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
|
||||
$this->Cell(0, 3, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
|
||||
|
||||
$this->SetFont('Arial', 'I', 7);
|
||||
$this->SetTextColor(150, 150, 150);
|
||||
$this->Cell(0, 3, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bloc legal / contextuel
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeContextBlock(): void
|
||||
{
|
||||
$this->SetY(35);
|
||||
|
||||
$labelW = 55;
|
||||
$dataW = 120;
|
||||
|
||||
// Ligne superieure
|
||||
$this->SetDrawColor(200, 200, 200);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(2);
|
||||
|
||||
$this->SetFont('Arial', 'B', 10);
|
||||
$this->Cell(0, 5, $this->enc('INFORMATIONS LEGALES ET CONTEXTUELLES'), 0, 1, 'C');
|
||||
$this->Ln(1);
|
||||
|
||||
$this->SetFont('Arial', '', 9);
|
||||
|
||||
// Association / RNA
|
||||
$this->Cell($labelW, 5, $this->enc('Association :'), 0, 0, 'L');
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->Cell($dataW, 5, $this->enc('E-Cosplay Association loi 1901 - RNA N W022006988'), 0, 1, 'L');
|
||||
$this->SetFont('Arial', '', 9);
|
||||
|
||||
// Siege social
|
||||
$this->Cell($labelW, 5, $this->enc('Siege social :'), 0, 0, 'L');
|
||||
$this->Cell($dataW, 5, $this->enc('42 rue de Saint-Quentin 02800 Beautor'), 0, 1, 'L');
|
||||
|
||||
// SIRET
|
||||
$this->Cell($labelW, 5, $this->enc('SIRET :'), 0, 0, 'L');
|
||||
$this->Cell($dataW, 5, $this->enc('943 121 517 00011'), 0, 1, 'L');
|
||||
|
||||
// Code APE
|
||||
$this->Cell($labelW, 5, $this->enc('Code APE :'), 0, 0, 'L');
|
||||
$this->Cell($dataW, 5, $this->enc('9329Z'), 0, 1, 'L');
|
||||
|
||||
// Document
|
||||
$this->Cell($labelW, 5, $this->enc('Document :'), 0, 0, 'L');
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->Cell($dataW, 5, $this->enc($this->documentTitle), 0, 1, 'L');
|
||||
$this->SetFont('Arial', '', 9);
|
||||
|
||||
// Periode
|
||||
$this->Cell($labelW, 5, $this->enc('Periode comptable :'), 0, 0, 'L');
|
||||
$this->Cell($dataW, 5, $this->enc('Du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L');
|
||||
|
||||
// Ligne inferieure
|
||||
$this->Ln(2);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tableau de donnees
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeDataTable(): void
|
||||
{
|
||||
if (empty($this->rows)) {
|
||||
$this->SetFont('Arial', '', 11);
|
||||
$this->Cell(0, 10, $this->enc('Aucune donnee sur cette periode.'), 0, 1, 'C');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// En-tete du tableau
|
||||
$this->SetFont('Arial', 'B', 7);
|
||||
$this->SetFillColor(35, 35, 35);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
|
||||
foreach ($this->columns as $col) {
|
||||
$w = $this->columnWidths[$col] ?? 25;
|
||||
$this->Cell($w, 6, $this->enc($col), 1, 0, 'C', true);
|
||||
}
|
||||
$this->Ln();
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
// Donnees
|
||||
$this->SetFont('Arial', '', 7);
|
||||
$fill = false;
|
||||
foreach ($this->rows as $row) {
|
||||
// Verifier si on a besoin d'une nouvelle page
|
||||
if ($this->GetY() + 5 > $this->GetPageHeight() - 30) {
|
||||
$this->AddPage('L');
|
||||
$this->SetY(35);
|
||||
|
||||
// Re-dessiner l'en-tete du tableau
|
||||
$this->SetFont('Arial', 'B', 7);
|
||||
$this->SetFillColor(35, 35, 35);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
foreach ($this->columns as $col) {
|
||||
$w = $this->columnWidths[$col] ?? 25;
|
||||
$this->Cell($w, 6, $this->enc($col), 1, 0, 'C', true);
|
||||
}
|
||||
$this->Ln();
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->SetFont('Arial', '', 7);
|
||||
$fill = false;
|
||||
}
|
||||
|
||||
$this->SetFillColor(245, 245, 240);
|
||||
foreach ($this->columns as $col) {
|
||||
$w = $this->columnWidths[$col] ?? 25;
|
||||
$value = $row[$col] ?? '';
|
||||
$align = $this->isNumericColumn($col) ? 'R' : 'L';
|
||||
$this->Cell($w, 5, $this->enc($value), 'B', 0, $align, $fill);
|
||||
}
|
||||
$this->Ln();
|
||||
$fill = !$fill;
|
||||
}
|
||||
|
||||
$this->Ln(3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Resume / totaux
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeSummary(): void
|
||||
{
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->Cell(0, 6, $this->enc('Total : '.\count($this->rows).' ecriture(s)'), 0, 1, 'L');
|
||||
|
||||
// Si colonnes Debit/Credit, afficher les totaux
|
||||
$totalDebit = 0.0;
|
||||
$totalCredit = 0.0;
|
||||
$hasDebitCredit = false;
|
||||
|
||||
foreach ($this->rows as $row) {
|
||||
if (isset($row['Debit'])) {
|
||||
$totalDebit += (float) $row['Debit'];
|
||||
$hasDebitCredit = true;
|
||||
}
|
||||
if (isset($row['Credit'])) {
|
||||
$totalCredit += (float) $row['Credit'];
|
||||
}
|
||||
// Grand livre / balance
|
||||
if (isset($row['MontantHT'])) {
|
||||
$totalDebit += (float) $row['MontantHT'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasDebitCredit) {
|
||||
$this->SetFont('Arial', '', 9);
|
||||
$this->Cell(60, 5, $this->enc('Total Debit : '.number_format($totalDebit, 2, ',', ' ')).' '.EURO, 0, 0, 'L');
|
||||
$this->Cell(60, 5, $this->enc('Total Credit : '.number_format($totalCredit, 2, ',', ' ')).' '.EURO, 0, 1, 'L');
|
||||
}
|
||||
|
||||
$this->Ln(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bloc signature (champ DocuSeal)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeSignatureBlock(): void
|
||||
{
|
||||
// S'assurer qu'on a assez de place
|
||||
if ($this->GetY() + 45 > $this->GetPageHeight() - 25) {
|
||||
$this->AddPage('L');
|
||||
$this->SetY(35);
|
||||
}
|
||||
|
||||
$this->SetDrawColor(200, 200, 200);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(3);
|
||||
|
||||
$formatter = new \IntlDateFormatter(
|
||||
'fr_FR',
|
||||
\IntlDateFormatter::LONG,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Europe/Paris',
|
||||
\IntlDateFormatter::GREGORIAN
|
||||
);
|
||||
|
||||
$this->SetFont('Arial', '', 9);
|
||||
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
|
||||
$this->Ln(2);
|
||||
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->Cell(0, 5, $this->enc('Signature du responsable :'), 0, 1, 'L');
|
||||
$this->Ln(1);
|
||||
|
||||
// Champ signature DocuSeal (meme format que DevisPdf)
|
||||
$this->SetAutoPageBreak(false);
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(60, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function computeColumnWidths(): array
|
||||
{
|
||||
if (empty($this->columns)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pageWidth = 277; // A4 landscape - margins
|
||||
$widths = [];
|
||||
|
||||
// Colonnes connues avec taille fixe
|
||||
$known = [
|
||||
'JournalCode' => 15,
|
||||
'JournalLib' => 28,
|
||||
'EcritureNum' => 22,
|
||||
'EcritureDate' => 20,
|
||||
'CompteNum' => 25,
|
||||
'CompteLib' => 35,
|
||||
'CompAuxNum' => 22,
|
||||
'CompAuxLib' => 30,
|
||||
'PieceRef' => 22,
|
||||
'PieceDate' => 20,
|
||||
'EcritureLib' => 45,
|
||||
'Debit' => 18,
|
||||
'Credit' => 18,
|
||||
'Lettrage' => 14,
|
||||
'DateLettrage' => 20,
|
||||
'ValidDate' => 20,
|
||||
'MontantDevise' => 18,
|
||||
'Idevise' => 12,
|
||||
'EcrtureLet' => 14,
|
||||
'DateLet' => 18,
|
||||
'Montantdevise' => 18,
|
||||
'Solde' => 18,
|
||||
'CodeJournal' => 15,
|
||||
'Statut' => 16,
|
||||
'MethodePaiement' => 24,
|
||||
'DatePaiement' => 20,
|
||||
'CodeComptable' => 25,
|
||||
'Client' => 40,
|
||||
'NumFacture' => 25,
|
||||
'DateFacture' => 20,
|
||||
'MontantHT' => 20,
|
||||
'MontantTVA' => 20,
|
||||
'MontantTTC' => 20,
|
||||
'JoursRetard' => 16,
|
||||
'Tranche' => 22,
|
||||
'DateReglement' => 22,
|
||||
'CompteBanque' => 20,
|
||||
];
|
||||
|
||||
$usedWidth = 0;
|
||||
$unknownCols = [];
|
||||
|
||||
foreach ($this->columns as $col) {
|
||||
if (isset($known[$col])) {
|
||||
$widths[$col] = $known[$col];
|
||||
$usedWidth += $known[$col];
|
||||
} else {
|
||||
$unknownCols[] = $col;
|
||||
}
|
||||
}
|
||||
|
||||
// Distribuer l'espace restant aux colonnes inconnues
|
||||
if (!empty($unknownCols)) {
|
||||
$remaining = max($pageWidth - $usedWidth, \count($unknownCols) * 15);
|
||||
$each = (int) floor($remaining / \count($unknownCols));
|
||||
foreach ($unknownCols as $col) {
|
||||
$widths[$col] = $each;
|
||||
}
|
||||
}
|
||||
|
||||
return $widths;
|
||||
}
|
||||
|
||||
private function isNumericColumn(string $col): bool
|
||||
{
|
||||
return \in_array($col, [
|
||||
'Debit', 'Credit', 'Solde',
|
||||
'MontantHT', 'MontantTVA', 'MontantTTC',
|
||||
'MontantDevise', 'Montantdevise',
|
||||
'JoursRetard',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function enc(string $text): string
|
||||
{
|
||||
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,14 @@ class DevisPdf extends Fpdi
|
||||
private array $items = [];
|
||||
|
||||
private int $lastDevisPage = 0;
|
||||
private ?\Twig\Environment $twig = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly Devis $devis,
|
||||
?\Twig\Environment $twig = null,
|
||||
) {
|
||||
$this->twig = $twig;
|
||||
parent::__construct();
|
||||
|
||||
$items = [];
|
||||
@@ -49,9 +52,9 @@ class DevisPdf extends Fpdi
|
||||
}
|
||||
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo_facture.png';
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
|
||||
if (file_exists($logo)) {
|
||||
$this->Image($logo, 65, 5, 80);
|
||||
$this->Image($logo, 10, 8, 53);
|
||||
}
|
||||
|
||||
$formatter = new \IntlDateFormatter(
|
||||
@@ -63,7 +66,7 @@ class DevisPdf extends Fpdi
|
||||
);
|
||||
|
||||
$numDevisText = $this->enc('DEVIS N° '.$this->devis->getOrderNumber()->getNumOrder());
|
||||
$dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->devis->getCreatedAt()));
|
||||
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->devis->getCreatedAt()));
|
||||
|
||||
$this->Text(15, 80, $numDevisText);
|
||||
$this->Text(15, 85, $dateText);
|
||||
@@ -148,24 +151,40 @@ class DevisPdf extends Fpdi
|
||||
}
|
||||
|
||||
$this->displaySummary();
|
||||
$this->displaySign();
|
||||
$this->appendCgv();
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe les pages de public/cgv.pdf a la suite du devis, sans appliquer le Header/Footer devis.
|
||||
* Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI.
|
||||
*/
|
||||
private function displaySign(): void
|
||||
{
|
||||
$this->SetAutoPageBreak(false);
|
||||
$this->SetXY(15, $this->GetPageHeight() - 45);
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L');
|
||||
}
|
||||
|
||||
private function appendCgv(): void
|
||||
{
|
||||
$cgvPath = $this->kernel->getProjectDir().'/public/cgv.pdf';
|
||||
if (!file_exists($cgvPath)) {
|
||||
if (null === $this->twig) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pageCount = $this->setSourceFile($cgvPath);
|
||||
$html = $this->twig->render('pdf/cgv.html.twig');
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
|
||||
file_put_contents($tmpCgv, $dompdf->output());
|
||||
|
||||
// Marque la derniere page devis : toutes les pages ajoutees apres auront le Header/Footer desactive
|
||||
$this->lastDevisPage = $this->PageNo();
|
||||
$pageCount = $this->setSourceFile($tmpCgv);
|
||||
|
||||
for ($i = 1; $i <= $pageCount; ++$i) {
|
||||
$tpl = $this->importPage($i);
|
||||
@@ -173,42 +192,50 @@ class DevisPdf extends Fpdi
|
||||
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
|
||||
$this->useTemplate($tpl);
|
||||
|
||||
// Sur la DERNIERE page CGV : ajoute un champ signature DocuSeal independant
|
||||
// en bas a gauche, sans declencher de saut de page automatique
|
||||
// Sur la DERNIERE page CGV : champ signature DocuSeal
|
||||
if ($i === $pageCount) {
|
||||
$this->SetAutoPageBreak(false);
|
||||
$this->SetXY(15, $this->GetPageHeight() - 100);
|
||||
$this->SetXY(15, $this->GetPageHeight() - 35);
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(60, 20, '{{SignCGV;type=signature;role=First Party}}', 0, 0, 'L');
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($tmpCgv);
|
||||
} catch (\Throwable) {
|
||||
// Silencieux : si CGV corrompu on garde juste le devis
|
||||
}
|
||||
}
|
||||
|
||||
private function displaySummary(): void
|
||||
{
|
||||
$totalHt = array_sum(array_column($this->items, 'priceHt'));
|
||||
$totalTva = $totalHt * 0.20;
|
||||
$totalTtc = $totalHt + $totalTva;
|
||||
$totalHt = (float) $this->devis->getTotalHt();
|
||||
$totalTva = (float) $this->devis->getTotalTva();
|
||||
$totalTtc = (float) $this->devis->getTotalTtc();
|
||||
$tvaEnabled = $totalTva > 0.01;
|
||||
|
||||
$this->SetY(-60);
|
||||
|
||||
// Zone signature (placeholder DocuSeal) en bas a gauche de la derniere page devis
|
||||
$this->Cell(30, 10, '{{Sign;type=signature;role=First Party}}', 0, 0, 'L');
|
||||
|
||||
$this->SetFont('Arial', '', 12);
|
||||
$this->Cell(100, 10, $this->enc('Total HT :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
$this->Cell(135, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
|
||||
$this->Cell(35, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetY($tvaEnabled ? -60 : -50);
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->Cell(135, 10, $this->enc('Total TTC :'), 0, 0, 'R');
|
||||
$this->Cell(35, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
if ($tvaEnabled) {
|
||||
$this->SetFont('Arial', '', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
} else {
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetX(105);
|
||||
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
|
||||
}
|
||||
}
|
||||
|
||||
public function Footer(): void
|
||||
@@ -218,19 +245,15 @@ class DevisPdf extends Fpdi
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetY(-32);
|
||||
$this->Ln(10);
|
||||
$this->SetFont('Arial', 'B', 8);
|
||||
$this->SetTextColor(253, 140, 4);
|
||||
$this->SetY(-28);
|
||||
$this->SetDrawColor(253, 140, 4);
|
||||
$this->Cell(190, 5, $this->enc('Partenaire de vos projects de puis 1997'), 0, 1, 'C');
|
||||
$this->Line(15, $this->GetY(), 195, $this->GetY());
|
||||
$this->Ln(4);
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Ln(2);
|
||||
$this->Cell(190, 4, $this->enc('27, rue le Sérurier - 02100 SAINT-QUENTIN - Tél: 03 23 05 62 43'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('e-mail : s.com@siteconseil.fr - www.siteconseil.fr'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('S.A.R.L aux captial de 71400 ').EURO.' - '.$this->enc('N°SIRET 418 664 058 00025 - N° TVA FR 05 418 664 058 - CODE APE 6201 Z - R.C. St-Quentin 418 664 058'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
|
||||
|
||||
// Numero de page avec alias {nb}
|
||||
$this->SetFont('Arial', 'I', 7);
|
||||
|
||||
338
src/Service/Pdf/FacturePdf.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Pdf;
|
||||
|
||||
use App\Entity\Facture;
|
||||
use Dompdf\Dompdf;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use setasign\Fpdi\Fpdi;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
if (!\defined('EURO')) {
|
||||
\define('EURO', \chr(128));
|
||||
}
|
||||
|
||||
class FacturePdf extends Fpdi
|
||||
{
|
||||
/** @var array<int, array{title: string, content: string, priceHt: float}> */
|
||||
private array $items = [];
|
||||
|
||||
private string $qrBase64 = '';
|
||||
private bool $skipHeaderFooter = false;
|
||||
private int $lastFacturePage = 0;
|
||||
private ?\Twig\Environment $twig = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly Facture $facture,
|
||||
?UrlGeneratorInterface $urlGenerator = null,
|
||||
?\Twig\Environment $twig = null,
|
||||
) {
|
||||
$this->twig = $twig;
|
||||
parent::__construct();
|
||||
|
||||
if (null !== $urlGenerator) {
|
||||
$verifyUrl = $urlGenerator->generate('app_facture_verify', [
|
||||
'id' => $facture->getId(),
|
||||
'hmac' => $facture->getHmac(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
$builder = new Builder(
|
||||
writer: new PngWriter(),
|
||||
data: $verifyUrl,
|
||||
size: 200,
|
||||
margin: 10,
|
||||
);
|
||||
$this->qrBase64 = 'data:image/png;base64,'.base64_encode($builder->build()->getString());
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($this->facture->getLines() as $line) {
|
||||
$items[$line->getPos()] = [
|
||||
'title' => $line->getTitle(),
|
||||
'content' => $line->getDescription() ?? '',
|
||||
'priceHt' => (float) $line->getPriceHt(),
|
||||
];
|
||||
}
|
||||
ksort($items);
|
||||
$this->items = $items;
|
||||
|
||||
$this->SetTitle($this->enc('Facture N° '.$this->facture->getInvoiceNumber()));
|
||||
}
|
||||
|
||||
public function Header(): void
|
||||
{
|
||||
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastFacturePage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
|
||||
if (file_exists($logo)) {
|
||||
$this->Image($logo, 10, 8, 53);
|
||||
}
|
||||
|
||||
$formatter = new \IntlDateFormatter(
|
||||
'fr_FR',
|
||||
\IntlDateFormatter::FULL,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Europe/Paris',
|
||||
\IntlDateFormatter::GREGORIAN
|
||||
);
|
||||
|
||||
$numText = $this->enc('FACTURE N° '.$this->facture->getInvoiceNumber());
|
||||
$dateText = $this->enc('Emis a Beautor, le '.$formatter->format($this->facture->getCreatedAt()));
|
||||
|
||||
$this->Text(15, 80, $numText);
|
||||
$this->Text(15, 85, $dateText);
|
||||
|
||||
// Mention payee
|
||||
if ($this->facture->isPaid()) {
|
||||
$this->SetFont('Arial', 'B', 10);
|
||||
$this->SetTextColor(22, 163, 74);
|
||||
$paidText = $this->enc('PAYEE le '.$this->facture->getPaidAt()?->format('d/m/Y').' par '.$this->facture->getPaidMethod());
|
||||
$this->Text(15, 90, $paidText);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
}
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
|
||||
$y = 60;
|
||||
$customer = $this->facture->getCustomer();
|
||||
|
||||
if (null !== $customer) {
|
||||
$name = $customer->getRaisonSociale() ?: $customer->getFullName();
|
||||
$this->Text(110, $y, $this->enc($name));
|
||||
|
||||
if ($address = $customer->getAddress()) {
|
||||
$y += 5;
|
||||
$this->Text(110, $y, $this->enc($address));
|
||||
}
|
||||
|
||||
if ($address2 = $customer->getAddress2()) {
|
||||
$y += 5;
|
||||
$this->Text(110, $y, $this->enc($address2));
|
||||
}
|
||||
|
||||
$y += 5;
|
||||
$cityLine = ($customer->getZipCode() ?? '').' '.($customer->getCity() ?? '');
|
||||
$this->Text(110, $y, $this->enc(trim($cityLine)));
|
||||
}
|
||||
|
||||
$this->body();
|
||||
}
|
||||
|
||||
private function body(): void
|
||||
{
|
||||
$this->SetFont('Arial', 'B', 10);
|
||||
$this->SetXY(145, 100);
|
||||
$this->Cell(40, 5, $this->enc('PRIX HT'), 0, 0, 'C');
|
||||
|
||||
$this->Line(145, 110, 145, 220);
|
||||
$this->Line(185, 110, 185, 220);
|
||||
|
||||
$this->Line(0, 100, 5, 100);
|
||||
$this->Line(0, 200, 5, 200);
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$this->AliasNbPages();
|
||||
$this->AddPage();
|
||||
$this->SetFont('Arial', '', 12);
|
||||
|
||||
$startY = 110;
|
||||
$this->SetY($startY);
|
||||
$contentBottomLimit = 220;
|
||||
|
||||
foreach ($this->items as $item) {
|
||||
if ($this->GetY() + 30 > $contentBottomLimit) {
|
||||
$this->AddPage();
|
||||
$this->body();
|
||||
$this->SetY($startY);
|
||||
}
|
||||
|
||||
$currentY = $this->GetY();
|
||||
|
||||
$this->SetX(20);
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->Cell(95, 10, $this->enc($item['title']), 0, 0);
|
||||
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->SetXY(142, $currentY);
|
||||
$this->Cell(39, 8, number_format($item['priceHt'], 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
$this->SetFont('Arial', '', 11);
|
||||
$this->SetX(30);
|
||||
if ('' !== $item['content']) {
|
||||
$this->MultiCell(90, 5, $this->enc($item['content']), 0, 'L');
|
||||
}
|
||||
|
||||
$this->Ln(5);
|
||||
}
|
||||
|
||||
$this->displaySummary();
|
||||
$this->displayQrCode();
|
||||
$this->appendCgv();
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Genere les CGV depuis le template Twig via Dompdf puis les importe via FPDI.
|
||||
*/
|
||||
private function appendCgv(): void
|
||||
{
|
||||
if (null === $this->twig) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Rendre le HTML des CGV via un template PDF dedie
|
||||
$html = $this->twig->render('pdf/cgv.html.twig');
|
||||
|
||||
// Generer un PDF temporaire via Dompdf
|
||||
$dompdf = new Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
$tmpCgv = tempnam(sys_get_temp_dir(), 'cgv_').'.pdf';
|
||||
file_put_contents($tmpCgv, $dompdf->output());
|
||||
|
||||
// Importer les pages dans le PDF FPDI
|
||||
$this->lastFacturePage = $this->PageNo();
|
||||
$this->skipHeaderFooter = true;
|
||||
$pageCount = $this->setSourceFile($tmpCgv);
|
||||
|
||||
for ($i = 1; $i <= $pageCount; ++$i) {
|
||||
$tpl = $this->importPage($i);
|
||||
$size = $this->getTemplateSize($tpl);
|
||||
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
|
||||
$this->useTemplate($tpl);
|
||||
}
|
||||
|
||||
@unlink($tmpCgv);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe les pages de public/rib.pdf apres les CGV.
|
||||
*/
|
||||
private function appendRib(): void
|
||||
{
|
||||
$ribPath = $this->kernel->getProjectDir().'/public/rib.pdf';
|
||||
if (!file_exists($ribPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pageCount = $this->setSourceFile($ribPath);
|
||||
|
||||
for ($i = 1; $i <= $pageCount; ++$i) {
|
||||
$tpl = $this->importPage($i);
|
||||
$size = $this->getTemplateSize($tpl);
|
||||
$this->AddPage($size['orientation'] ?? 'P', [$size['width'], $size['height']]);
|
||||
$this->useTemplate($tpl);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function displayHmac(): void
|
||||
{
|
||||
$this->Ln(6);
|
||||
$this->SetFont('Arial', '', 6);
|
||||
$this->SetTextColor(150, 150, 150);
|
||||
$this->Cell(190, 4, $this->enc('Signature HMAC-SHA256 : '.$this->facture->getHmac()), 0, 1, 'L');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
}
|
||||
|
||||
private function displayQrCode(): void
|
||||
{
|
||||
if ('' === $this->qrBase64) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetAutoPageBreak(false);
|
||||
$y = $this->GetPageHeight() - 95;
|
||||
|
||||
$tmpQr = tempnam(sys_get_temp_dir(), 'qr_').'.png';
|
||||
$pngData = base64_decode(str_replace('data:image/png;base64,', '', $this->qrBase64));
|
||||
file_put_contents($tmpQr, $pngData);
|
||||
|
||||
$this->Image($tmpQr, 15, $y, 25, 25);
|
||||
@unlink($tmpQr);
|
||||
|
||||
$this->SetXY(15, $y + 27);
|
||||
$this->SetFont('Arial', 'B', 8);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(50, 4, $this->enc('Verifier cette facture'), 0, 1, 'L');
|
||||
$this->SetX(15);
|
||||
$this->SetFont('Arial', '', 7);
|
||||
$this->SetTextColor(100, 100, 100);
|
||||
$this->Cell(60, 3, $this->enc('Scannez pour verifier l\'authenticite'), 0, 1, 'L');
|
||||
$this->SetX(15);
|
||||
$this->Cell(60, 3, $this->enc('de ce document.'), 0, 1, 'L');
|
||||
}
|
||||
|
||||
private function displaySummary(): void
|
||||
{
|
||||
$totalHt = (float) $this->facture->getTotalHt();
|
||||
$totalTva = (float) $this->facture->getTotalTva();
|
||||
$totalTtc = (float) $this->facture->getTotalTtc();
|
||||
$tvaEnabled = $totalTva > 0.01;
|
||||
|
||||
$this->SetY($tvaEnabled ? -60 : -50);
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('Total HT :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalHt, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
if ($tvaEnabled) {
|
||||
$this->SetFont('Arial', '', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('TVA (20%) :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalTva, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->SetX(105);
|
||||
$this->Cell(40, 10, $this->enc('Total TTC :'), 0, 0, 'R');
|
||||
$this->Cell(40, 10, number_format($totalTtc, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
} else {
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetX(105);
|
||||
$this->Cell(80, 5, $this->enc('TVA non applicable - art. 293 B du CGI'), 0, 1, 'R');
|
||||
}
|
||||
}
|
||||
|
||||
public function Footer(): void
|
||||
{
|
||||
if ($this->skipHeaderFooter && $this->PageNo() > $this->lastFacturePage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->SetY(-28);
|
||||
$this->SetDrawColor(253, 140, 4);
|
||||
$this->Line(15, $this->GetY(), 195, $this->GetY());
|
||||
$this->Ln(4);
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tél: 06 79 34 88 02'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('e-mail : contact@e-cosplay.fr - www.e-cosplay.fr'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N°SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
|
||||
|
||||
$this->SetFont('Arial', 'I', 7);
|
||||
$this->SetTextColor(150, 150, 150);
|
||||
$this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
|
||||
}
|
||||
|
||||
private function enc(string $text): string
|
||||
{
|
||||
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
|
||||
}
|
||||
}
|
||||
355
src/Service/Pdf/RapportFinancierPdf.php
Normal file
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Pdf;
|
||||
|
||||
use setasign\Fpdi\Fpdi;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
if (!\defined('EURO')) {
|
||||
\define('EURO', \chr(128));
|
||||
}
|
||||
|
||||
class RapportFinancierPdf extends Fpdi
|
||||
{
|
||||
private string $periodFrom;
|
||||
private string $periodTo;
|
||||
|
||||
/** @var array<string, float> */
|
||||
private array $recettes = [];
|
||||
|
||||
/** @var array<string, float> */
|
||||
private array $depenses = [];
|
||||
|
||||
private float $totalRecettes = 0.0;
|
||||
private float $totalDepenses = 0.0;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
string $periodFrom,
|
||||
string $periodTo,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->periodFrom = $periodFrom;
|
||||
$this->periodTo = $periodTo;
|
||||
|
||||
$this->SetTitle($this->enc('Rapport financier - '.$periodFrom.' au '.$periodTo));
|
||||
$this->SetAuthor($this->enc('Association E-Cosplay'));
|
||||
$this->SetCreator('CRM E-Cosplay');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, float> $recettes Libelle => montant
|
||||
* @param array<string, float> $depenses Libelle => montant
|
||||
*/
|
||||
public function setData(array $recettes, array $depenses): void
|
||||
{
|
||||
$this->recettes = $recettes;
|
||||
$this->depenses = $depenses;
|
||||
$this->totalRecettes = array_sum($recettes);
|
||||
$this->totalDepenses = array_sum($depenses);
|
||||
}
|
||||
|
||||
public function generate(): void
|
||||
{
|
||||
$this->AliasNbPages();
|
||||
$this->AddPage();
|
||||
|
||||
$this->writeContextBlock();
|
||||
$this->writeRecettes();
|
||||
$this->writeDepenses();
|
||||
$this->writeBilan();
|
||||
$this->writeSignatureBlock();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Header / Footer
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
public function Header(): void
|
||||
{
|
||||
$logo = $this->kernel->getProjectDir().'/public/logo.jpg';
|
||||
if (file_exists($logo)) {
|
||||
$this->Image($logo, 10, 8, 45);
|
||||
}
|
||||
|
||||
$this->SetFont('Arial', 'B', 18);
|
||||
$this->SetXY(60, 10);
|
||||
$this->Cell(0, 8, $this->enc('RAPPORT FINANCIER'), 0, 1, 'L');
|
||||
|
||||
$this->SetFont('Arial', '', 11);
|
||||
$this->SetXY(60, 19);
|
||||
$this->Cell(0, 5, $this->enc('Periode : du '.$this->periodFrom.' au '.$this->periodTo), 0, 1, 'L');
|
||||
|
||||
$formatter = new \IntlDateFormatter(
|
||||
'fr_FR',
|
||||
\IntlDateFormatter::FULL,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Europe/Paris',
|
||||
\IntlDateFormatter::GREGORIAN
|
||||
);
|
||||
$this->SetFont('Arial', '', 9);
|
||||
$this->SetTextColor(120, 120, 120);
|
||||
$this->SetXY(60, 25);
|
||||
$this->Cell(0, 5, $this->enc('Edite le '.$formatter->format(new \DateTime())), 0, 1, 'L');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
// Mention document public
|
||||
$this->SetFont('Arial', 'I', 8);
|
||||
$this->SetTextColor(100, 100, 100);
|
||||
$this->SetXY(10, 35);
|
||||
$this->Cell(0, 4, $this->enc('Document public - Association loi 1901 - Les montants sont presentes de maniere synthetique.'), 0, 1, 'C');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->Ln(5);
|
||||
}
|
||||
|
||||
public function Footer(): void
|
||||
{
|
||||
$this->SetY(-22);
|
||||
$this->SetDrawColor(253, 140, 4);
|
||||
$this->Line(15, $this->GetY(), 195, $this->GetY());
|
||||
$this->Ln(3);
|
||||
$this->SetFont('Arial', '', 8);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(190, 4, $this->enc('42 rue de Saint-Quentin - 02800 BEAUTOR - Tel: 06 79 34 88 02 - contact@e-cosplay.fr'), 0, 1, 'C');
|
||||
$this->Cell(190, 4, $this->enc('Association E-Cosplay - N SIRET 943 121 517 00011 - CODE APE 9329Z - RNA W022006988'), 0, 1, 'C');
|
||||
|
||||
$this->SetFont('Arial', 'I', 7);
|
||||
$this->SetTextColor(150, 150, 150);
|
||||
$this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bloc legal
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeContextBlock(): void
|
||||
{
|
||||
$this->SetY(45);
|
||||
|
||||
$this->SetDrawColor(200, 200, 200);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(2);
|
||||
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->Cell(0, 6, $this->enc('INFORMATIONS LEGALES'), 0, 1, 'C');
|
||||
$this->Ln(1);
|
||||
|
||||
$labelW = 55;
|
||||
$this->SetFont('Arial', '', 9);
|
||||
|
||||
$this->Cell($labelW, 5, $this->enc('Association :'), 0, 0, 'L');
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->Cell(0, 5, $this->enc('E-Cosplay - Association loi 1901 - RNA W022006988'), 0, 1, 'L');
|
||||
$this->SetFont('Arial', '', 9);
|
||||
|
||||
$this->Cell($labelW, 5, $this->enc('Siege social :'), 0, 0, 'L');
|
||||
$this->Cell(0, 5, $this->enc('42 rue de Saint-Quentin, 02800 Beautor'), 0, 1, 'L');
|
||||
|
||||
$this->Cell($labelW, 5, $this->enc('SIRET :'), 0, 0, 'L');
|
||||
$this->Cell(0, 5, $this->enc('943 121 517 00011'), 0, 1, 'L');
|
||||
|
||||
$this->Cell($labelW, 5, $this->enc('Activite :'), 0, 0, 'L');
|
||||
$this->Cell(0, 5, $this->enc('Services numeriques (hebergement, noms de domaine, messagerie)'), 0, 1, 'L');
|
||||
|
||||
$this->Ln(2);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Recettes (montant entrant)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeRecettes(): void
|
||||
{
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->SetTextColor(22, 163, 74);
|
||||
$this->Cell(0, 7, $this->enc('RECETTES (MONTANTS ENTRANTS)'), 0, 1, 'L');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Ln(2);
|
||||
|
||||
// En-tete
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->SetFillColor(35, 35, 35);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
$this->Cell(120, 7, $this->enc(' Service'), 1, 0, 'L', true);
|
||||
$this->Cell(50, 7, $this->enc('Montant HT'), 1, 1, 'R', true);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
// Lignes
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$fill = false;
|
||||
foreach ($this->recettes as $label => $montant) {
|
||||
$this->SetFillColor(245, 245, 240);
|
||||
$this->Cell(120, 7, $this->enc(' '.$label), 'B', 0, 'L', $fill);
|
||||
$this->Cell(50, 7, number_format($montant, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill);
|
||||
$fill = !$fill;
|
||||
}
|
||||
|
||||
// Total
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->SetFillColor(22, 163, 74);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
$this->Cell(120, 8, $this->enc(' TOTAL RECETTES'), 0, 0, 'L', true);
|
||||
$this->Cell(50, 8, number_format($this->totalRecettes, 2, ',', ' ').' '.EURO, 0, 1, 'R', true);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->Ln(8);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Depenses (montant sortant)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeDepenses(): void
|
||||
{
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
$this->SetTextColor(220, 38, 38);
|
||||
$this->Cell(0, 7, $this->enc('DEPENSES (MONTANTS SORTANTS)'), 0, 1, 'L');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Ln(2);
|
||||
|
||||
// En-tete
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->SetFillColor(35, 35, 35);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
$this->Cell(120, 7, $this->enc(' Poste de depense'), 1, 0, 'L', true);
|
||||
$this->Cell(50, 7, $this->enc('Montant'), 1, 1, 'R', true);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
// Lignes
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$fill = false;
|
||||
foreach ($this->depenses as $label => $montant) {
|
||||
$this->SetFillColor(245, 245, 240);
|
||||
$this->Cell(120, 7, $this->enc(' '.$label), 'B', 0, 'L', $fill);
|
||||
$this->Cell(50, 7, number_format($montant, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill);
|
||||
$fill = !$fill;
|
||||
}
|
||||
|
||||
// Total
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->SetFillColor(220, 38, 38);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
$this->Cell(120, 8, $this->enc(' TOTAL DEPENSES'), 0, 0, 'L', true);
|
||||
$this->Cell(50, 8, number_format($this->totalDepenses, 2, ',', ' ').' '.EURO, 0, 1, 'R', true);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->Ln(8);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bilan
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeBilan(): void
|
||||
{
|
||||
$marge = $this->totalRecettes - $this->totalDepenses;
|
||||
$isPositif = $marge >= 0;
|
||||
|
||||
$this->SetDrawColor(200, 200, 200);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(3);
|
||||
|
||||
$this->SetFont('Arial', 'B', 13);
|
||||
$this->Cell(0, 7, $this->enc('BILAN'), 0, 1, 'C');
|
||||
$this->Ln(2);
|
||||
|
||||
// Tableau bilan
|
||||
$this->SetFont('Arial', '', 11);
|
||||
$col1 = 90;
|
||||
$col2 = 80;
|
||||
|
||||
$this->SetX(15);
|
||||
$this->Cell($col1, 8, $this->enc('Total des recettes :'), 0, 0, 'L');
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->SetTextColor(22, 163, 74);
|
||||
$this->Cell($col2, 8, '+ '.number_format($this->totalRecettes, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->SetFont('Arial', '', 11);
|
||||
$this->SetX(15);
|
||||
$this->Cell($col1, 8, $this->enc('Total des depenses :'), 0, 0, 'L');
|
||||
$this->SetFont('Arial', 'B', 11);
|
||||
$this->SetTextColor(220, 38, 38);
|
||||
$this->Cell($col2, 8, '- '.number_format($this->totalDepenses, 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->Ln(1);
|
||||
$this->SetDrawColor(0, 0, 0);
|
||||
$this->Line(15, $this->GetY(), 185, $this->GetY());
|
||||
$this->Ln(2);
|
||||
|
||||
// Resultat
|
||||
$this->SetFont('Arial', 'B', 14);
|
||||
$this->SetX(15);
|
||||
$this->Cell($col1, 10, $this->enc('Resultat net :'), 0, 0, 'L');
|
||||
$this->SetTextColor($isPositif ? 22 : 220, $isPositif ? 163 : 38, $isPositif ? 74 : 38);
|
||||
$sign = $isPositif ? '+ ' : '- ';
|
||||
$this->Cell($col2, 10, $sign.number_format(abs($marge), 2, ',', ' ').' '.EURO, 0, 1, 'R');
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
// Statut
|
||||
$this->Ln(3);
|
||||
$this->SetFont('Arial', 'B', 12);
|
||||
if ($isPositif) {
|
||||
$this->SetFillColor(22, 163, 74);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
$label = $marge > $this->totalRecettes * 0.3 ? 'EXCEDENT' : 'EQUILIBRE';
|
||||
} else {
|
||||
$this->SetFillColor(220, 38, 38);
|
||||
$this->SetTextColor(255, 255, 255);
|
||||
$label = 'DEFICIT';
|
||||
}
|
||||
$this->Cell(0, 10, $this->enc($label), 0, 1, 'C', true);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
|
||||
$this->Ln(5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Signature
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function writeSignatureBlock(): void
|
||||
{
|
||||
if ($this->GetY() + 40 > $this->GetPageHeight() - 25) {
|
||||
$this->AddPage();
|
||||
}
|
||||
|
||||
$this->SetDrawColor(200, 200, 200);
|
||||
$this->Cell(0, 0.5, '', 'T', 1, 'L');
|
||||
$this->Ln(3);
|
||||
|
||||
$formatter = new \IntlDateFormatter(
|
||||
'fr_FR',
|
||||
\IntlDateFormatter::LONG,
|
||||
\IntlDateFormatter::NONE,
|
||||
'Europe/Paris',
|
||||
\IntlDateFormatter::GREGORIAN
|
||||
);
|
||||
|
||||
$this->SetFont('Arial', '', 9);
|
||||
$this->Cell(0, 5, $this->enc('Fait a Beautor, le '.$formatter->format(new \DateTime())), 0, 1, 'L');
|
||||
$this->Ln(2);
|
||||
|
||||
$this->SetFont('Arial', 'B', 9);
|
||||
$this->Cell(0, 5, $this->enc('Signature du responsable :'), 0, 1, 'L');
|
||||
$this->Ln(1);
|
||||
|
||||
$this->SetAutoPageBreak(false);
|
||||
$this->SetFont('Arial', '', 10);
|
||||
$this->SetTextColor(0, 0, 0);
|
||||
$this->Cell(60, 20, '{{Sign;type=signature;role=First Party}}', 0, 1, 'L');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
private function enc(string $text): string
|
||||
{
|
||||
return mb_convert_encoding($text, 'Windows-1252', 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,13 @@ use Twig\Environment;
|
||||
|
||||
class RgpdService
|
||||
{
|
||||
private const CODE_EXPIRY_MINUTES = 15;
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Environment $twig,
|
||||
private DocuSealService $docuSealService,
|
||||
private MailerService $mailer,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
#[Autowire('%kernel.project_dir%')] private string $projectDir,
|
||||
#[Autowire('%env(APP_SECRET)%')] private string $hmacSecret,
|
||||
@@ -49,6 +52,92 @@ class RgpdService
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genere et envoie un code de verification par email.
|
||||
*/
|
||||
public function sendVerificationCode(string $email, string $ip, string $type): void
|
||||
{
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', \STR_PAD_LEFT);
|
||||
|
||||
// Stocke le code en session-like via un hash deterministe
|
||||
$codeHash = $this->generateCodeHash($email, $ip, $type, $code);
|
||||
|
||||
// Stocke en fichier temporaire (expire dans 15 min)
|
||||
$codePath = $this->getCodeFilePath($email, $ip, $type);
|
||||
file_put_contents($codePath, json_encode([
|
||||
'code' => $code,
|
||||
'hash' => $codeHash,
|
||||
'expires' => time() + (self::CODE_EXPIRY_MINUTES * 60),
|
||||
]));
|
||||
|
||||
$typeName = 'access' === $type ? 'acces a vos donnees' : 'suppression de vos donnees';
|
||||
|
||||
$this->mailer->sendEmail(
|
||||
$email,
|
||||
'Code de verification RGPD - '.$typeName,
|
||||
$this->twig->render('emails/rgpd_verification_code.html.twig', [
|
||||
'code' => $code,
|
||||
'type' => $type,
|
||||
'typeName' => $typeName,
|
||||
'expiryMinutes' => self::CODE_EXPIRY_MINUTES,
|
||||
]),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie le code saisi par l'utilisateur.
|
||||
*/
|
||||
public function verifyCode(string $email, string $ip, string $type, string $code): bool
|
||||
{
|
||||
$codePath = $this->getCodeFilePath($email, $ip, $type);
|
||||
|
||||
if (!file_exists($codePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($codePath), true);
|
||||
if (null === $data) {
|
||||
@unlink($codePath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Expire ?
|
||||
if (time() > ($data['expires'] ?? 0)) {
|
||||
@unlink($codePath);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Code correct ?
|
||||
if ($code !== ($data['code'] ?? '')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Supprime le fichier (usage unique)
|
||||
@unlink($codePath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getCodeFilePath(string $email, string $ip, string $type): string
|
||||
{
|
||||
$dir = $this->projectDir.'/var/rgpd/codes';
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0o755, true);
|
||||
}
|
||||
|
||||
return $dir.'/'.hash('sha256', $email.'|'.$ip.'|'.$type.'|'.$this->hmacSecret).'.json';
|
||||
}
|
||||
|
||||
private function generateCodeHash(string $email, string $ip, string $type, string $code): string
|
||||
{
|
||||
return hash_hmac('sha256', $email.'|'.$ip.'|'.$type.'|'.$code, $this->hmacSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{found: bool, count: int}
|
||||
*/
|
||||
@@ -139,7 +228,7 @@ class RgpdService
|
||||
|
||||
private function getLogoBase64(): string
|
||||
{
|
||||
$logoPath = $this->projectDir.'/public/logo_facture.png';
|
||||
$logoPath = $this->projectDir.'/public/logo.jpg';
|
||||
|
||||
return file_exists($logoPath) ? 'data:image/jpeg;base64,'.base64_encode((string) file_get_contents($logoPath)) : '';
|
||||
}
|
||||
|
||||
173
src/Service/SentryService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Wrapper API Sentry pour gerer les projets, issues et DSN programmatiquement.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class SentryService
|
||||
{
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire(env: 'SENTRY_AUTH_TOKEN')] private string $authToken = '',
|
||||
#[Autowire(env: 'SENTRY_ORG')] private string $defaultOrg = '',
|
||||
#[Autowire(env: 'SENTRY_API_URL')] private string $apiUrl = 'https://sentry.io/api/0',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return '' !== $this->authToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree un projet Sentry dans une equipe.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function createProject(string $teamSlug, string $name, string $org = ''): ?array
|
||||
{
|
||||
$org = $this->resolveOrg($org);
|
||||
|
||||
return $this->request('POST', '/teams/'.$org.'/'.$teamSlug.'/projects/', ['name' => $name]);
|
||||
}
|
||||
|
||||
public function deleteProject(string $projectSlug, string $org = ''): bool
|
||||
{
|
||||
$org = $this->resolveOrg($org);
|
||||
|
||||
return null !== $this->request('DELETE', '/projects/'.$org.'/'.$projectSlug.'/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere le DSN public du projet.
|
||||
*/
|
||||
public function getProjectDsn(string $projectSlug, string $org = ''): ?string
|
||||
{
|
||||
$org = $this->resolveOrg($org);
|
||||
$result = $this->request('GET', '/projects/'.$org.'/'.$projectSlug.'/keys/');
|
||||
|
||||
if (null === $result || !isset($result[0]['dsn']['public'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result[0]['dsn']['public'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les projets de l'organisation.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listProjects(string $org = ''): array
|
||||
{
|
||||
$org = $this->resolveOrg($org);
|
||||
|
||||
return $this->request('GET', '/organizations/'.$org.'/projects/') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les issues/erreurs d'un projet.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listProjectErrors(string $projectSlug, ?string $query = null, string $org = ''): array
|
||||
{
|
||||
$org = $this->resolveOrg($org);
|
||||
$path = '/projects/'.$org.'/'.$projectSlug.'/issues/';
|
||||
|
||||
if (null !== $query && '' !== $query) {
|
||||
$path .= '?query='.urlencode($query);
|
||||
}
|
||||
|
||||
return $this->request('GET', $path) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere les details d'une issue.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getIssueDetails(string $issueId): ?array
|
||||
{
|
||||
return $this->request('GET', '/issues/'.$issueId.'/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque une issue comme resolue.
|
||||
*/
|
||||
public function resolveIssue(string $issueId): bool
|
||||
{
|
||||
return null !== $this->request('PUT', '/issues/'.$issueId.'/', ['status' => 'resolved']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une issue.
|
||||
*/
|
||||
public function deleteIssue(string $issueId): bool
|
||||
{
|
||||
return null !== $this->request('DELETE', '/issues/'.$issueId.'/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function request(string $method, string $path, ?array $body = null): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
$this->logger->warning('SentryService: auth token non configure');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$options = [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$this->authToken,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
if (null !== $body) {
|
||||
$options['json'] = $body;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->request($method, rtrim($this->apiUrl, '/').$path, $options);
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
if ($statusCode >= 400) {
|
||||
$this->logger->error('SentryService: HTTP '.$statusCode.' '.$method.' '.$path, [
|
||||
'body' => $response->getContent(false),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// DELETE retourne 204 sans body
|
||||
if (204 === $statusCode || '' === $response->getContent(false)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('SentryService: '.$e->getMessage(), [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveOrg(string $org): string
|
||||
{
|
||||
return '' !== $org ? $org : $this->defaultOrg;
|
||||
}
|
||||
}
|
||||
523
src/Service/SeoService.php
Normal file
@@ -0,0 +1,523 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class SeoService
|
||||
{
|
||||
private const USER_AGENT = 'E-Cosplay-SEO-Checker/1.0';
|
||||
private const TIMEOUT = 10;
|
||||
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse les meta tags d'une page (title, description, og:*, canonical).
|
||||
*
|
||||
* @return array{title: ?string, description: ?string, og_title: ?string, og_description: ?string, og_image: ?string, canonical: ?string, issues: list<string>}
|
||||
*/
|
||||
public function checkMetaTags(string $url): array
|
||||
{
|
||||
$result = [
|
||||
'title' => null,
|
||||
'description' => null,
|
||||
'og_title' => null,
|
||||
'og_description' => null,
|
||||
'og_image' => null,
|
||||
'canonical' => null,
|
||||
'issues' => [],
|
||||
];
|
||||
|
||||
$html = $this->fetchHtml($url);
|
||||
if (null === $html) {
|
||||
$result['issues'][] = 'Impossible de recuperer la page';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Title
|
||||
if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) {
|
||||
$result['title'] = trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8'));
|
||||
} else {
|
||||
$result['issues'][] = 'Balise <title> manquante';
|
||||
}
|
||||
|
||||
if (null !== $result['title'] && \strlen($result['title']) > 60) {
|
||||
$result['issues'][] = 'Title trop long ('.\strlen($result['title']).' caracteres, max 60)';
|
||||
}
|
||||
|
||||
// Meta description
|
||||
$result['description'] = $this->extractMetaTag($html, 'description');
|
||||
if (null === $result['description']) {
|
||||
$result['issues'][] = 'Meta description manquante';
|
||||
} elseif (\strlen($result['description']) > 160) {
|
||||
$result['issues'][] = 'Meta description trop longue ('.\strlen($result['description']).' caracteres, max 160)';
|
||||
}
|
||||
|
||||
// Open Graph
|
||||
$result['og_title'] = $this->extractMetaTag($html, 'og:title', 'property');
|
||||
$result['og_description'] = $this->extractMetaTag($html, 'og:description', 'property');
|
||||
$result['og_image'] = $this->extractMetaTag($html, 'og:image', 'property');
|
||||
|
||||
if (null === $result['og_title']) {
|
||||
$result['issues'][] = 'Meta og:title manquante';
|
||||
}
|
||||
if (null === $result['og_image']) {
|
||||
$result['issues'][] = 'Meta og:image manquante';
|
||||
}
|
||||
|
||||
// Canonical
|
||||
if (preg_match('/<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)["\']/i', $html, $m)) {
|
||||
$result['canonical'] = $m[1];
|
||||
} else {
|
||||
$result['issues'][] = 'Balise canonical manquante';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie le fichier sitemap.xml.
|
||||
*
|
||||
* @return array{exists: bool, valid: bool, url_count: int, issues: list<string>}
|
||||
*/
|
||||
public function checkSitemap(string $siteUrl): array
|
||||
{
|
||||
$result = ['exists' => false, 'valid' => false, 'url_count' => 0, 'issues' => []];
|
||||
$sitemapUrl = rtrim($siteUrl, '/').'/sitemap.xml';
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $sitemapUrl, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => ['User-Agent' => self::USER_AGENT],
|
||||
]);
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$result['issues'][] = 'sitemap.xml retourne HTTP '.$response->getStatusCode();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['exists'] = true;
|
||||
$content = $response->getContent();
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($content);
|
||||
if (false === $xml) {
|
||||
$result['issues'][] = 'sitemap.xml invalide (XML malformed)';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['valid'] = true;
|
||||
$xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9');
|
||||
$urls = $xml->xpath('//sm:url');
|
||||
$result['url_count'] = false !== $urls ? \count($urls) : 0;
|
||||
|
||||
if (0 === $result['url_count']) {
|
||||
// Essai sans namespace
|
||||
$urls = $xml->xpath('//url');
|
||||
$result['url_count'] = false !== $urls ? \count($urls) : 0;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result['issues'][] = 'Erreur acces sitemap.xml : '.$e->getMessage();
|
||||
$this->logger->error('SeoService::checkSitemap: '.$e->getMessage(), ['url' => $sitemapUrl]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie le fichier robots.txt.
|
||||
*
|
||||
* @return array{exists: bool, content: string, has_sitemap: bool, has_disallow: bool, issues: list<string>}
|
||||
*/
|
||||
public function checkRobotsTxt(string $siteUrl): array
|
||||
{
|
||||
$result = ['exists' => false, 'content' => '', 'has_sitemap' => false, 'has_disallow' => false, 'issues' => []];
|
||||
$robotsUrl = rtrim($siteUrl, '/').'/robots.txt';
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $robotsUrl, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => ['User-Agent' => self::USER_AGENT],
|
||||
]);
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$result['issues'][] = 'robots.txt retourne HTTP '.$response->getStatusCode();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['exists'] = true;
|
||||
$result['content'] = $response->getContent();
|
||||
$result['has_sitemap'] = (bool) preg_match('/^Sitemap:/mi', $result['content']);
|
||||
$result['has_disallow'] = (bool) preg_match('/^Disallow:/mi', $result['content']);
|
||||
|
||||
if (!$result['has_sitemap']) {
|
||||
$result['issues'][] = 'robots.txt ne reference pas de Sitemap';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result['issues'][] = 'Erreur acces robots.txt : '.$e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie la presence du meta viewport (responsive).
|
||||
*
|
||||
* @return array{has_viewport: bool, viewport_content: ?string, issues: list<string>}
|
||||
*/
|
||||
public function checkMobileResponsive(string $url): array
|
||||
{
|
||||
$result = ['has_viewport' => false, 'viewport_content' => null, 'issues' => []];
|
||||
|
||||
$html = $this->fetchHtml($url);
|
||||
if (null === $html) {
|
||||
$result['issues'][] = 'Impossible de recuperer la page';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$viewport = $this->extractMetaTag($html, 'viewport');
|
||||
if (null !== $viewport) {
|
||||
$result['has_viewport'] = true;
|
||||
$result['viewport_content'] = $viewport;
|
||||
} else {
|
||||
$result['issues'][] = 'Meta viewport manquante (site non responsive)';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesure le temps de reponse HTTP.
|
||||
*
|
||||
* @return array{load_time_ms: int, status_code: int, issues: list<string>}
|
||||
*/
|
||||
public function checkPageSpeed(string $url): array
|
||||
{
|
||||
$result = ['load_time_ms' => 0, 'status_code' => 0, 'issues' => []];
|
||||
|
||||
try {
|
||||
$start = microtime(true);
|
||||
$response = $this->httpClient->request('GET', $url, [
|
||||
'timeout' => 30,
|
||||
'headers' => ['User-Agent' => self::USER_AGENT],
|
||||
]);
|
||||
$response->getContent();
|
||||
$result['load_time_ms'] = (int) ((microtime(true) - $start) * 1000);
|
||||
$result['status_code'] = $response->getStatusCode();
|
||||
|
||||
if ($result['load_time_ms'] > 3000) {
|
||||
$result['issues'][] = 'Page lente ('.$result['load_time_ms'].'ms, seuil recommande 3s)';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result['issues'][] = 'Erreur chargement : '.$e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie le certificat SSL.
|
||||
*
|
||||
* @return array{is_https: bool, valid: bool, issuer: ?string, expires: ?string, days_remaining: ?int, issues: list<string>}
|
||||
*/
|
||||
public function checkSslCertificate(string $url): array
|
||||
{
|
||||
$result = ['is_https' => false, 'valid' => false, 'issuer' => null, 'expires' => null, 'days_remaining' => null, 'issues' => []];
|
||||
|
||||
$parsed = parse_url($url);
|
||||
if (!isset($parsed['host'])) {
|
||||
$result['issues'][] = 'URL invalide';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['is_https'] = 'https' === ($parsed['scheme'] ?? '');
|
||||
if (!$result['is_https']) {
|
||||
$result['issues'][] = 'Site non HTTPS';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$host = $parsed['host'];
|
||||
$port = $parsed['port'] ?? 443;
|
||||
|
||||
try {
|
||||
$context = stream_context_create(['ssl' => ['capture_peer_cert' => true, 'verify_peer' => false]]);
|
||||
$socket = @stream_socket_client('ssl://'.$host.':'.$port, $errno, $errstr, 10, \STREAM_CLIENT_CONNECT, $context);
|
||||
|
||||
if (false === $socket) {
|
||||
$result['issues'][] = 'Connexion SSL impossible : '.$errstr;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$params = stream_context_get_params($socket);
|
||||
fclose($socket);
|
||||
|
||||
$cert = $params['options']['ssl']['peer_certificate'] ?? null;
|
||||
if (null === $cert) {
|
||||
$result['issues'][] = 'Certificat non recuperable';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$certInfo = openssl_x509_parse($cert);
|
||||
if (false === $certInfo) {
|
||||
$result['issues'][] = 'Certificat non parsable';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['valid'] = true;
|
||||
$result['issuer'] = $certInfo['issuer']['O'] ?? $certInfo['issuer']['CN'] ?? null;
|
||||
$result['expires'] = date('Y-m-d', $certInfo['validTo_time_t']);
|
||||
$result['days_remaining'] = (int) ((($certInfo['validTo_time_t']) - time()) / 86400);
|
||||
|
||||
if ($result['days_remaining'] < 0) {
|
||||
$result['valid'] = false;
|
||||
$result['issues'][] = 'Certificat expire depuis '.abs($result['days_remaining']).' jours';
|
||||
} elseif ($result['days_remaining'] < 30) {
|
||||
$result['issues'][] = 'Certificat expire dans '.$result['days_remaining'].' jours';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result['issues'][] = 'Erreur verification SSL : '.$e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifie les headers de securite.
|
||||
*
|
||||
* @return array{headers: array<string, string>, missing: list<string>, issues: list<string>}
|
||||
*/
|
||||
public function checkHeaders(string $url): array
|
||||
{
|
||||
$result = ['headers' => [], 'missing' => [], 'issues' => []];
|
||||
$expected = ['X-Frame-Options', 'Content-Security-Policy', 'Strict-Transport-Security', 'X-Content-Type-Options'];
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('HEAD', $url, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => ['User-Agent' => self::USER_AGENT],
|
||||
]);
|
||||
|
||||
$responseHeaders = $response->getHeaders(false);
|
||||
foreach ($expected as $header) {
|
||||
$lower = strtolower($header);
|
||||
if (isset($responseHeaders[$lower])) {
|
||||
$result['headers'][$header] = implode(', ', $responseHeaders[$lower]);
|
||||
} else {
|
||||
$result['missing'][] = $header;
|
||||
$result['issues'][] = 'Header '.$header.' manquant';
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result['issues'][] = 'Erreur verification headers : '.$e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande a Google de re-indexer une URL (purge cache + reindex).
|
||||
* Utilise GoogleSearchService::submitUrlForIndexing en interne.
|
||||
*
|
||||
* @return array{success: bool, url: string, message: string}
|
||||
*/
|
||||
public function purgeAndReindex(string $url, GoogleSearchService $googleSearch): array
|
||||
{
|
||||
if (!$googleSearch->isAvailable()) {
|
||||
return ['success' => false, 'url' => $url, 'message' => 'Google Search Console non configure'];
|
||||
}
|
||||
|
||||
$result = $googleSearch->submitUrlForIndexing($url);
|
||||
|
||||
if ($result) {
|
||||
$this->logger->info('SeoService::purgeAndReindex: URL soumise pour reindexation', ['url' => $url]);
|
||||
|
||||
return ['success' => true, 'url' => $url, 'message' => 'URL soumise a Google pour reindexation'];
|
||||
}
|
||||
|
||||
$this->logger->error('SeoService::purgeAndReindex: echec soumission URL', ['url' => $url]);
|
||||
|
||||
return ['success' => false, 'url' => $url, 'message' => 'Echec de la soumission a Google Indexing API'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge et reindexe toutes les URLs d'un sitemap.
|
||||
*
|
||||
* @return array{total: int, success: int, failed: int, results: list<array{success: bool, url: string, message: string}>}
|
||||
*/
|
||||
public function purgeAllFromSitemap(string $siteUrl, GoogleSearchService $googleSearch): array
|
||||
{
|
||||
$sitemap = $this->checkSitemap($siteUrl);
|
||||
$results = ['total' => 0, 'success' => 0, 'failed' => 0, 'results' => []];
|
||||
|
||||
if (!$sitemap['exists'] || !$sitemap['valid']) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$sitemapUrl = rtrim($siteUrl, '/').'/sitemap.xml';
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $sitemapUrl, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => ['User-Agent' => self::USER_AGENT],
|
||||
]);
|
||||
|
||||
$xml = simplexml_load_string($response->getContent());
|
||||
if (false === $xml) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
$xml->registerXPathNamespace('sm', 'http://www.sitemaps.org/schemas/sitemap/0.9');
|
||||
$urls = $xml->xpath('//sm:url/sm:loc') ?: $xml->xpath('//url/loc') ?: [];
|
||||
|
||||
foreach ($urls as $loc) {
|
||||
$pageUrl = (string) $loc;
|
||||
$r = $this->purgeAndReindex($pageUrl, $googleSearch);
|
||||
$results['results'][] = $r;
|
||||
++$results['total'];
|
||||
$r['success'] ? ++$results['success'] : ++$results['failed'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('SeoService::purgeAllFromSitemap: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score SEO global (0-100) base sur tous les checks.
|
||||
*
|
||||
* @return array{score: int, details: array<string, mixed>}
|
||||
*/
|
||||
public function getPageScore(string $url): array
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
$siteUrl = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? '');
|
||||
|
||||
$meta = $this->checkMetaTags($url);
|
||||
$sitemap = $this->checkSitemap($siteUrl);
|
||||
$robots = $this->checkRobotsTxt($siteUrl);
|
||||
$mobile = $this->checkMobileResponsive($url);
|
||||
$speed = $this->checkPageSpeed($url);
|
||||
$ssl = $this->checkSslCertificate($url);
|
||||
$headers = $this->checkHeaders($url);
|
||||
|
||||
$score = 100;
|
||||
|
||||
// Meta tags (30 points max)
|
||||
if (null === $meta['title']) {
|
||||
$score -= 10;
|
||||
}
|
||||
if (null === $meta['description']) {
|
||||
$score -= 10;
|
||||
}
|
||||
if (null === $meta['og_title']) {
|
||||
$score -= 5;
|
||||
}
|
||||
if (null === $meta['canonical']) {
|
||||
$score -= 5;
|
||||
}
|
||||
|
||||
// Sitemap + robots (15 points)
|
||||
if (!$sitemap['exists']) {
|
||||
$score -= 10;
|
||||
}
|
||||
if (!$robots['exists']) {
|
||||
$score -= 5;
|
||||
}
|
||||
|
||||
// Mobile (10 points)
|
||||
if (!$mobile['has_viewport']) {
|
||||
$score -= 10;
|
||||
}
|
||||
|
||||
// Speed (15 points)
|
||||
if ($speed['load_time_ms'] > 5000) {
|
||||
$score -= 15;
|
||||
} elseif ($speed['load_time_ms'] > 3000) {
|
||||
$score -= 10;
|
||||
} elseif ($speed['load_time_ms'] > 1500) {
|
||||
$score -= 5;
|
||||
}
|
||||
|
||||
// SSL (15 points)
|
||||
if (!$ssl['is_https']) {
|
||||
$score -= 15;
|
||||
} elseif (!$ssl['valid']) {
|
||||
$score -= 10;
|
||||
}
|
||||
|
||||
// Headers (15 points)
|
||||
$score -= \count($headers['missing']) * 4;
|
||||
|
||||
return [
|
||||
'score' => max(0, $score),
|
||||
'details' => [
|
||||
'meta' => $meta,
|
||||
'sitemap' => $sitemap,
|
||||
'robots' => $robots,
|
||||
'mobile' => $mobile,
|
||||
'speed' => $speed,
|
||||
'ssl' => $ssl,
|
||||
'headers' => $headers,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchHtml(string $url): ?string
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $url, [
|
||||
'timeout' => self::TIMEOUT,
|
||||
'headers' => ['User-Agent' => self::USER_AGENT],
|
||||
]);
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->getContent();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('SeoService::fetchHtml: '.$e->getMessage(), ['url' => $url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function extractMetaTag(string $html, string $name, string $attribute = 'name'): ?string
|
||||
{
|
||||
$pattern = '/<meta[^>]+'.preg_quote($attribute, '/').'=["\']'.preg_quote($name, '/').
|
||||
'["\'][^>]+content=["\']([^"\']*)["\'][^>]*>/i';
|
||||
|
||||
if (preg_match($pattern, $html, $m)) {
|
||||
return trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
|
||||
// Ordre inverse : content avant name/property
|
||||
$pattern2 = '/<meta[^>]+content=["\']([^"\']*)["\'][^>]+'.preg_quote($attribute, '/').'=["\']'.preg_quote($name, '/').
|
||||
'["\'][^>]*>/i';
|
||||
|
||||
if (preg_match($pattern2, $html, $m)) {
|
||||
return trim(html_entity_decode($m[1], \ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,9 @@ class StripeWebhookService
|
||||
'invoice.finalized',
|
||||
'invoice.payment_succeeded',
|
||||
'invoice.payment_failed',
|
||||
'subscription.created',
|
||||
'subscription.updated',
|
||||
'subscription.deleted',
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
];
|
||||
|
||||
/** Evenements instantanes (paiements, actions critiques) */
|
||||
@@ -104,7 +104,7 @@ class StripeWebhookService
|
||||
'url' => $wh['url'],
|
||||
'enabled_events' => $wh['events'],
|
||||
'connect' => $wh['connect'],
|
||||
'description' => 'CRM SITECONSEIL - '.$wh['type'],
|
||||
'description' => 'CRM E-Cosplay - '.$wh['type'],
|
||||
]);
|
||||
|
||||
$created[] = ['type' => $wh['type'], 'url' => $wh['url'], 'id' => $endpoint->id, 'status' => 'created', 'secret' => $endpoint->secret];
|
||||
|
||||
@@ -19,75 +19,109 @@ class TarificationService
|
||||
|
||||
// NOSONAR - data configuration, not duplicated logic
|
||||
private const DEFAULT_PRICES = [
|
||||
'esyweb_business' => [
|
||||
'title' => 'Esy-Web Business',
|
||||
'description' => 'Licence Esy-Web + installation technique. Le client remplit le site en autonomie.',
|
||||
'esite_business' => [
|
||||
'title' => 'E-Site Basique',
|
||||
'description' => 'Creation, hebergement et maintenance de site internet realise avec Esy-Web. Le client remplit le site en autonomie.',
|
||||
'priceHt' => self::PRICE_500,
|
||||
'monthPrice' => self::PRICE_100,
|
||||
'period' => 1,
|
||||
'lineType' => 'website',
|
||||
],
|
||||
'esyweb_premium' => [
|
||||
'title' => 'Esy-Web Premium',
|
||||
'description' => 'Creation complete du site par SITECONSEIL avec accompagnement jusqu\'a la mise en ligne.',
|
||||
'priceHt' => '3200.00',
|
||||
'esite_premium' => [
|
||||
'title' => 'E-Site Avancee',
|
||||
'description' => 'Creation, hebergement et maintenance de site internet realise avec Esy-Web. Accompagnement complet jusqu\'a la mise en ligne.',
|
||||
'priceHt' => '2000.00',
|
||||
'monthPrice' => self::PRICE_100,
|
||||
'period' => 1,
|
||||
'lineType' => 'website',
|
||||
],
|
||||
'ecommerce_business' => [
|
||||
'title' => 'E-Commerce Business',
|
||||
'description' => 'Licence Esy-Web full options + E-boutique. Produits illimites.',
|
||||
'esite_ecommerce_business' => [
|
||||
'title' => 'E-Site E-Commerce Basique',
|
||||
'description' => 'Creation, hebergement et maintenance de site E-Commerce realise avec Esy-Web. Produits illimites.',
|
||||
'priceHt' => '999.00',
|
||||
'monthPrice' => '150.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'website',
|
||||
],
|
||||
'ecommerce_premium' => [
|
||||
'title' => 'E-Commerce Premium',
|
||||
'description' => 'Creation complete du site E-Commerce par SITECONSEIL. Produits illimites.',
|
||||
'priceHt' => '5110.00',
|
||||
'esite_ecommerce_premium' => [
|
||||
'title' => 'E-Site E-Commerce Avancee',
|
||||
'description' => 'Creation, hebergement et maintenance de site E-Commerce realise avec Esy-Web. Accompagnement complet. Produits illimites.',
|
||||
'priceHt' => '3100.00',
|
||||
'monthPrice' => '150.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'website',
|
||||
],
|
||||
'esymail' => [
|
||||
'title' => 'Esy-Mail',
|
||||
'description' => 'Messagerie professionnelle. 2 boites mail, 5 Go, antispam, RGPD.',
|
||||
'priceHt' => self::PRICE_50,
|
||||
'monthPrice' => self::PRICE_30,
|
||||
'email_3go' => [
|
||||
'title' => 'E-Mail 3 Go',
|
||||
'description' => 'Boite mail professionnelle 3 Go. Antispam, antivirus, RGPD, IMAP/POP/SMTP.',
|
||||
'priceHt' => '1.00',
|
||||
'monthPrice' => '1.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'esymail',
|
||||
],
|
||||
'esymailer' => [
|
||||
'title' => 'Esy-Mailer',
|
||||
'email_50go' => [
|
||||
'title' => 'E-Mail 50 Go',
|
||||
'description' => 'Boite mail professionnelle 50 Go. Antispam, antivirus, RGPD, IMAP/POP/SMTP.',
|
||||
'priceHt' => '5.00',
|
||||
'monthPrice' => '5.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'esymail',
|
||||
],
|
||||
'email_mise_en_service' => [
|
||||
'title' => 'E-Mail - Mise en service',
|
||||
'description' => 'Mise en service de la messagerie professionnelle E-Mail.',
|
||||
'priceHt' => '25.00',
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'esymail',
|
||||
],
|
||||
'email_supp_3go' => [
|
||||
'title' => 'E-Mail - Boite supplementaire 3 Go',
|
||||
'description' => 'Boite mail supplementaire 3 Go. Antispam, antivirus, RGPD.',
|
||||
'priceHt' => '1.00',
|
||||
'monthPrice' => '1.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'esymail',
|
||||
],
|
||||
'email_supp_50go' => [
|
||||
'title' => 'E-Mail - Boite supplementaire 50 Go',
|
||||
'description' => 'Boite mail supplementaire 50 Go. Antispam, antivirus, RGPD.',
|
||||
'priceHt' => '5.00',
|
||||
'monthPrice' => '5.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'esymail',
|
||||
],
|
||||
'emailer' => [
|
||||
'title' => 'E-Mailer',
|
||||
'description' => 'Envoi de mail en masse. 15 000 mails/mois.',
|
||||
'priceHt' => self::PRICE_50,
|
||||
'monthPrice' => self::PRICE_30,
|
||||
'period' => 1,
|
||||
'lineType' => 'esymail',
|
||||
],
|
||||
'esydefender_pro' => [
|
||||
'title' => 'Esy-Defender Pro',
|
||||
'title' => 'E-Protect Pro',
|
||||
'description' => 'Cyber defense avancee. Anti-DDoS, pare-feu, filtrage geo, surveillance.',
|
||||
'priceHt' => self::PRICE_50,
|
||||
'monthPrice' => '60.00',
|
||||
'period' => 3,
|
||||
'lineType' => 'hosting',
|
||||
],
|
||||
'esymeet' => [
|
||||
'title' => 'Esy-Meet',
|
||||
'title' => 'E-Calendar',
|
||||
'description' => 'Prise de rendez-vous en ligne via Cal.com.',
|
||||
'priceHt' => self::PRICE_50,
|
||||
'monthPrice' => self::PRICE_30,
|
||||
'period' => 1,
|
||||
'lineType' => 'website',
|
||||
],
|
||||
'esytchat' => [
|
||||
'title' => 'Esy-Tchat',
|
||||
'title' => 'E-Chat',
|
||||
'description' => 'Chat en ligne sur votre site via Chatwoot.',
|
||||
'priceHt' => self::PRICE_50,
|
||||
'monthPrice' => '15.00',
|
||||
'period' => 1,
|
||||
],
|
||||
'esycreator' => [
|
||||
'title' => 'Esy-Creator',
|
||||
'description' => 'Maintenance graphique et editoriale du site.',
|
||||
'priceHt' => self::PRICE_500,
|
||||
'monthPrice' => self::PRICE_100,
|
||||
'period' => 3,
|
||||
'lineType' => 'website',
|
||||
],
|
||||
'ndd_depot' => [
|
||||
'title' => 'Nom de domaine - Depot',
|
||||
@@ -95,6 +129,7 @@ class TarificationService
|
||||
'priceHt' => '20.00',
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'ndd',
|
||||
],
|
||||
'ndd_renouvellement' => [
|
||||
'title' => 'Nom de domaine - Renouvellement',
|
||||
@@ -102,6 +137,7 @@ class TarificationService
|
||||
'priceHt' => '20.00',
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 12,
|
||||
'lineType' => 'ndd',
|
||||
],
|
||||
'ndd_gestion' => [
|
||||
'title' => 'Nom de domaine - Gestion',
|
||||
@@ -109,6 +145,7 @@ class TarificationService
|
||||
'priceHt' => self::PRICE_30,
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 12,
|
||||
'lineType' => 'ndd',
|
||||
],
|
||||
'ndd_reactivation' => [
|
||||
'title' => 'Nom de domaine - Reactivation',
|
||||
@@ -116,6 +153,7 @@ class TarificationService
|
||||
'priceHt' => self::PRICE_50,
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'ndd',
|
||||
],
|
||||
'formation_pack10h' => [
|
||||
'title' => 'Pack 10 heures de formation',
|
||||
@@ -123,6 +161,7 @@ class TarificationService
|
||||
'priceHt' => self::PRICE_500,
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'other',
|
||||
],
|
||||
'formation_heure' => [
|
||||
'title' => 'Formation a la demande',
|
||||
@@ -130,6 +169,7 @@ class TarificationService
|
||||
'priceHt' => '70.00',
|
||||
'monthPrice' => '0.00',
|
||||
'period' => 1,
|
||||
'lineType' => 'other',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||