diff --git a/ansible/deploy.yml.disabled b/ansible/deploy.yml.disabled index 61af1eb..6cf66f4 100644 --- a/ansible/deploy.yml.disabled +++ b/ansible/deploy.yml.disabled @@ -296,6 +296,14 @@ job: "docker compose -f /var/www/crm-siteconseil/docker-compose-prod.yml exec -T php php bin/console app:clean:pending-delete --env=prod >> /var/log/crm-siteconseil-clean-pending.log 2>&1" user: bot + - name: Configure NDD expiration check cron (daily at 8am) + cron: + name: "crm-siteconseil ndd check" + minute: "0" + hour: "8" + 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 Meilisearch full reindex cron (weekly Sunday at 4am) cron: name: "crm-siteconseil meilisearch reindex" diff --git a/assets/app.js b/assets/app.js index 6f80240..37e7805 100644 --- a/assets/app.js +++ b/assets/app.js @@ -273,4 +273,199 @@ document.addEventListener('DOMContentLoaded', () => { if (e.key === 'Escape') globalResults.classList.add('hidden'); }); } + + // ──────── Tab search devis / avis ──────── + initTabSearch('search-devis', 'search-devis-results'); + initTabSearch('search-adverts', 'search-adverts-results'); + + // ──────── Devis lines repeater + drag & drop ──────── + initDevisLines(); + + // ──────── Devis process : toggle formulaire de refus ──────── + const refuseBtn = document.getElementById('refuse-toggle-btn'); + const refuseForm = document.getElementById('refuse-form'); + if (refuseBtn && refuseForm) { + refuseBtn.addEventListener('click', () => refuseForm.classList.toggle('hidden')); + } }); + +function initTabSearch(inputId, resultsId) { + const input = document.getElementById(inputId); + const results = document.getElementById(resultsId); + if (!input || !results) return; + + const url = input.dataset.url; + let timeout = null; + + const stateLabels = { + created: 'Cree', send: 'Envoye', accepted: 'Accepte', refused: 'Refuse', cancel: 'Annule' + }; + const stateColors = { + created: 'bg-yellow-100 text-yellow-800', send: 'bg-blue-500/20 text-blue-700', + accepted: 'bg-green-500/20 text-green-700', refused: 'bg-red-500/20 text-red-700', + cancel: 'bg-gray-100 text-gray-600' + }; + + input.addEventListener('input', () => { + clearTimeout(timeout); + const q = input.value.trim(); + if (q.length < 2) { results.classList.add('hidden'); return; } + + timeout = setTimeout(async () => { + const resp = await fetch(url + '?q=' + encodeURIComponent(q)); + const hits = await resp.json(); + if (hits.length === 0) { + results.innerHTML = '
Aucun resultat.
'; + } else { + results.innerHTML = hits.map(h => + `
+
+ ${h.numOrder} + ${h.customerName || ''} +
+
+ ${h.totalTtc || '0.00'} € + ${stateLabels[h.state] || h.state} +
+
` + ).join(''); + } + results.classList.remove('hidden'); + }, 250); + }); + + document.addEventListener('click', (e) => { + if (!results.contains(e.target) && e.target !== input) results.classList.add('hidden'); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') results.classList.add('hidden'); + }); +} + +function initDevisLines() { + const container = document.getElementById('lines-container'); + const addBtn = document.getElementById('add-line-btn'); + const tplEl = document.getElementById('line-template'); + const totalEl = document.getElementById('total-ht'); + if (!container || !addBtn || !tplEl || !totalEl) return; + + const template = tplEl.innerHTML; + let counter = 0; + + function renumber() { + container.querySelectorAll('.line-row').forEach((row, idx) => { + row.querySelector('.line-pos').textContent = '#' + (idx + 1); + row.querySelector('.line-pos-input').value = idx; + }); + } + + function recalc() { + let total = 0; + container.querySelectorAll('.line-price').forEach(input => { + const v = parseFloat(input.value); + if (!isNaN(v)) total += v; + }); + totalEl.textContent = total.toFixed(2) + ' EUR'; + } + + function addLine() { + const html = template.replaceAll('__INDEX__', counter); + const wrapper = document.createElement('div'); + wrapper.innerHTML = html.trim(); + const node = wrapper.firstChild; + container.appendChild(node); + counter++; + renumber(); + recalc(); + } + + container.addEventListener('click', e => { + if (e.target.classList.contains('remove-line-btn')) { + e.target.closest('.line-row').remove(); + renumber(); + recalc(); + } + }); + + container.addEventListener('input', e => { + if (e.target.classList.contains('line-price')) recalc(); + }); + + addBtn.addEventListener('click', () => addLine()); + + // Boutons prestations rapides : ajoute une ligne pre-remplie + document.querySelectorAll('.quick-price-btn').forEach(btn => { + btn.addEventListener('click', () => { + addLine(); + const lastRow = container.querySelector('.line-row:last-child'); + if (!lastRow) return; + lastRow.querySelector('input[name$="[title]"]').value = btn.dataset.title || ''; + lastRow.querySelector('textarea[name$="[description]"]').value = btn.dataset.description || ''; + const priceInput = lastRow.querySelector('.line-price'); + priceInput.value = btn.dataset.price || '0.00'; + recalc(); + }); + }); + + // Drag & drop reordering + let draggedRow = null; + + container.addEventListener('dragstart', e => { + const row = e.target.closest('.line-row'); + if (!row) return; + draggedRow = row; + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + + container.addEventListener('dragend', e => { + const row = e.target.closest('.line-row'); + if (row) row.classList.remove('dragging'); + container.querySelectorAll('.line-row').forEach(r => r.classList.remove('drag-over')); + draggedRow = null; + renumber(); + }); + + container.addEventListener('dragover', e => { + e.preventDefault(); + const target = e.target.closest('.line-row'); + if (!target || target === draggedRow) return; + container.querySelectorAll('.line-row').forEach(r => r.classList.remove('drag-over')); + target.classList.add('drag-over'); + }); + + container.addEventListener('drop', e => { + e.preventDefault(); + const target = e.target.closest('.line-row'); + if (!target || !draggedRow || target === draggedRow) return; + const rows = Array.from(container.querySelectorAll('.line-row')); + const draggedIdx = rows.indexOf(draggedRow); + const targetIdx = rows.indexOf(target); + if (draggedIdx < targetIdx) { + target.after(draggedRow); + } else { + target.before(draggedRow); + } + target.classList.remove('drag-over'); + renumber(); + }); + + // Prefill en mode edition + const initial = container.dataset.initialLines; + if (initial) { + try { + const arr = JSON.parse(initial); + arr.sort((a, b) => (a.pos || 0) - (b.pos || 0)); + arr.forEach(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'; + }); + recalc(); + } catch (e) { /* ignore */ } + } +} diff --git a/assets/app.scss b/assets/app.scss index 5f5371f..f70ca99 100644 --- a/assets/app.scss +++ b/assets/app.scss @@ -266,3 +266,7 @@ body.glass-bg { * { scroll-behavior: smooth; } + +/* ─── Devis lines drag & drop ─── */ +.line-row.dragging { opacity: 0.4; } +.line-row.drag-over { border-top: 2px solid #fabf04; } diff --git a/composer.json b/composer.json index bd24a82..18bc483 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "scheb/2fa-bundle": "^8.5", "scheb/2fa-email": "^8.5", "scheb/2fa-google-authenticator": "^8.5", + "setasign/fpdi-fpdf": "^2.3", "spomky-labs/pwa-bundle": "1.5.7", "stevenmaguire/oauth2-keycloak": "^6.1.1", "stripe/stripe-php": ">=20", diff --git a/composer.lock b/composer.lock index c625ec3..0708471 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9234633550a505d8b9b7ed8ee0118699", + "content-hash": "244b3a1727654cb9e75c662bf3cbac14", "packages": [ { "name": "async-aws/core", @@ -4864,6 +4864,167 @@ }, "time": "2026-01-24T13:27:55+00:00" }, + { + "name": "setasign/fpdf", + "version": "1.8.6", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDF.git", + "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", + "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", + "shasum": "" + }, + "require": { + "ext-gd": "*", + "ext-zlib": "*" + }, + "type": "library", + "autoload": { + "classmap": [ + "fpdf.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Olivier Plathey", + "email": "oliver@fpdf.org", + "homepage": "http://fpdf.org/" + } + ], + "description": "FPDF is a PHP class which allows to generate PDF files with pure PHP. F from FPDF stands for Free: you may use it for any kind of usage and modify it to suit your needs.", + "homepage": "http://www.fpdf.org", + "keywords": [ + "fpdf", + "pdf" + ], + "support": { + "source": "https://github.com/Setasign/FPDF/tree/1.8.6" + }, + "time": "2023-06-26T14:44:25+00:00" + }, + { + "name": "setasign/fpdi", + "version": "v2.6.6", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDI.git", + "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/de0cf35911be3e9ea63b48e0f307883b1c7c48ac", + "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac", + "shasum": "" + }, + "require": { + "ext-zlib": "*", + "php": ">=7.2 <=8.5.99999" + }, + "conflict": { + "setasign/tfpdf": "<1.31" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52", + "setasign/fpdf": "~1.8.6", + "setasign/tfpdf": "~1.33", + "squizlabs/php_codesniffer": "^3.5", + "tecnickcom/tcpdf": "^6.8" + }, + "suggest": { + "setasign/fpdf": "FPDI will extend this class but as it is also possible to use TCPDF or tFPDF as an alternative. There's no fixed dependency configured." + }, + "type": "library", + "autoload": { + "psr-4": { + "setasign\\Fpdi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Slabon", + "email": "jan.slabon@setasign.com", + "homepage": "https://www.setasign.com" + }, + { + "name": "Maximilian Kresse", + "email": "maximilian.kresse@setasign.com", + "homepage": "https://www.setasign.com" + } + ], + "description": "FPDI is a collection of PHP classes facilitating developers to read pages from existing PDF documents and use them as templates in FPDF. Because it is also possible to use FPDI with TCPDF, there are no fixed dependencies defined. Please see suggestions for packages which evaluates the dependencies automatically.", + "homepage": "https://www.setasign.com/fpdi", + "keywords": [ + "fpdf", + "fpdi", + "pdf" + ], + "support": { + "issues": "https://github.com/Setasign/FPDI/issues", + "source": "https://github.com/Setasign/FPDI/tree/v2.6.6" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/setasign/fpdi", + "type": "tidelift" + } + ], + "time": "2026-03-13T08:38:20+00:00" + }, + { + "name": "setasign/fpdi-fpdf", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/Setasign/FPDI-FPDF.git", + "reference": "f2fdc44e4d5247a3bb55ed2c2c1396ef05c02357" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Setasign/FPDI-FPDF/zipball/f2fdc44e4d5247a3bb55ed2c2c1396ef05c02357", + "reference": "f2fdc44e4d5247a3bb55ed2c2c1396ef05c02357", + "shasum": "" + }, + "require": { + "setasign/fpdf": "^1.8.2", + "setasign/fpdi": "^2.3" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Slabon", + "email": "jan.slabon@setasign.com", + "homepage": "https://www.setasign.com" + } + ], + "description": "Kind of metadata package for dependencies of the latest versions of FPDI and FPDF.", + "homepage": "https://www.setasign.com/fpdi", + "keywords": [ + "fpdf", + "fpdi", + "pdf" + ], + "support": { + "source": "https://github.com/Setasign/FPDI-FPDF/tree/v2.3.0" + }, + "abandoned": true, + "time": "2020-02-19T12:21:53+00:00" + }, { "name": "spomky-labs/otphp", "version": "11.4.2", diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index f156116..0031ec6 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -95,3 +95,5 @@ nelmio_security: - dashboard.stripe.com - auth.esy-web.dev - challenges.cloudflare.com + - signature.esy-web.dev + - signature.siteconseil.fr diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index b2a3fab..26217b4 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -8,5 +8,9 @@ vich_uploader: namer: Vich\UploaderBundle\Naming\SmartUniqueNamer devis_pdf: uri_prefix: /uploads/devis - upload_destination: '%kernel.project_dir%/var/uploads/devis' + upload_destination: '%kernel.project_dir%/public/uploads/devis' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + advert_pdf: + uri_prefix: /uploads/adverts + upload_destination: '%kernel.project_dir%/public/uploads/adverts' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer diff --git a/config/reference.php b/config/reference.php index b30abce..aaa1f84 100644 --- a/config/reference.php +++ b/config/reference.php @@ -638,7 +638,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * uid?: bool|array{ // Uid configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * default_uuid_version?: 7|6|4|1|Param, // Default: 7 * name_based_uuid_version?: 5|3|Param, // Default: 5 * name_based_uuid_namespace?: scalar|Param|null, diff --git a/docker/cron/crontab b/docker/cron/crontab index 2c175af..a3ac51e 100644 --- a/docker/cron/crontab +++ b/docker/cron/crontab @@ -10,3 +10,4 @@ 0 6 * * * echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] START app:dns:check" >> /proc/1/fd/1 && php /app/bin/console app:dns:check --env=dev >> /proc/1/fd/1 2>&1 && echo "[$(date '+\%Y-\%m-\%d \%H:\%M:\%S')] END app:dns:check" >> /proc/1/fd/1 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 diff --git a/migrations/Version20260405091832.php b/migrations/Version20260405091832.php new file mode 100644 index 0000000..4da3ff5 --- /dev/null +++ b/migrations/Version20260405091832.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE devis_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, devis_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_9EC6D52941DEFADA ON devis_line (devis_id)'); + $this->addSql('ALTER TABLE devis_line ADD CONSTRAINT FK_9EC6D52941DEFADA FOREIGN KEY (devis_id) REFERENCES devis (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 devis_line DROP CONSTRAINT FK_9EC6D52941DEFADA'); + $this->addSql('DROP TABLE devis_line'); + } +} diff --git a/migrations/Version20260405093556.php b/migrations/Version20260405093556.php new file mode 100644 index 0000000..4e7132e --- /dev/null +++ b/migrations/Version20260405093556.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE devis ADD submission_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 devis DROP submission_id'); + } +} diff --git a/migrations/Version20260405102507.php b/migrations/Version20260405102507.php new file mode 100644 index 0000000..ff8d48e --- /dev/null +++ b/migrations/Version20260405102507.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE devis ADD customer_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE devis ADD CONSTRAINT FK_8B27C52B9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('CREATE INDEX IDX_8B27C52B9395C3F3 ON devis (customer_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE devis DROP CONSTRAINT FK_8B27C52B9395C3F3'); + $this->addSql('DROP INDEX IDX_8B27C52B9395C3F3'); + $this->addSql('ALTER TABLE devis DROP customer_id'); + } +} diff --git a/migrations/Version20260405171822.php b/migrations/Version20260405171822.php new file mode 100644 index 0000000..e57458c --- /dev/null +++ b/migrations/Version20260405171822.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE docuseal_event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, event_type VARCHAR(30) NOT NULL, submission_id INT DEFAULT NULL, submitter_id INT DEFAULT NULL, payload TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX idx_docuseal_event_submission ON docuseal_event (submission_id)'); + $this->addSql('CREATE INDEX idx_docuseal_event_submitter ON docuseal_event (submitter_id)'); + $this->addSql('CREATE INDEX idx_docuseal_event_type ON docuseal_event (event_type)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE docuseal_event'); + } +} diff --git a/migrations/Version20260407065007.php b/migrations/Version20260407065007.php new file mode 100644 index 0000000..6a70664 --- /dev/null +++ b/migrations/Version20260407065007.php @@ -0,0 +1,60 @@ +addSql('CREATE TABLE advert_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, advert_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_9834F5BBD07ECCB6 ON advert_line (advert_id)'); + $this->addSql('ALTER TABLE advert_line ADD CONSTRAINT FK_9834F5BBD07ECCB6 FOREIGN KEY (advert_id) REFERENCES advert (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('DROP INDEX idx_54f1f40b41defada'); + $this->addSql('ALTER TABLE advert ADD state VARCHAR(20) DEFAULT \'created\' NOT NULL'); + $this->addSql('ALTER TABLE advert ADD raison_message TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE advert ADD total_ht NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL'); + $this->addSql('ALTER TABLE advert ADD total_tva NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL'); + $this->addSql('ALTER TABLE advert ADD total_ttc NUMERIC(10, 2) DEFAULT \'0.00\' NOT NULL'); + $this->addSql('ALTER TABLE advert ADD submission_id VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE advert ADD advert_file VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE advert ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql('ALTER TABLE advert ADD customer_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE advert ADD CONSTRAINT FK_54F1F40B9395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) ON DELETE SET NULL NOT DEFERRABLE'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_54F1F40B41DEFADA ON advert (devis_id)'); + $this->addSql('CREATE INDEX IDX_54F1F40B9395C3F3 ON advert (customer_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE advert_line DROP CONSTRAINT FK_9834F5BBD07ECCB6'); + $this->addSql('DROP TABLE advert_line'); + $this->addSql('ALTER TABLE advert DROP CONSTRAINT FK_54F1F40B9395C3F3'); + $this->addSql('DROP INDEX UNIQ_54F1F40B41DEFADA'); + $this->addSql('DROP INDEX IDX_54F1F40B9395C3F3'); + $this->addSql('ALTER TABLE advert DROP state'); + $this->addSql('ALTER TABLE advert DROP raison_message'); + $this->addSql('ALTER TABLE advert DROP total_ht'); + $this->addSql('ALTER TABLE advert DROP total_tva'); + $this->addSql('ALTER TABLE advert DROP total_ttc'); + $this->addSql('ALTER TABLE advert DROP submission_id'); + $this->addSql('ALTER TABLE advert DROP advert_file'); + $this->addSql('ALTER TABLE advert DROP updated_at'); + $this->addSql('ALTER TABLE advert DROP customer_id'); + $this->addSql('CREATE INDEX idx_54f1f40b41defada ON advert (devis_id)'); + } +} diff --git a/public/cgv.pdf b/public/cgv.pdf new file mode 100644 index 0000000..d220366 Binary files /dev/null and b/public/cgv.pdf differ diff --git a/public/logo_facture.png b/public/logo_facture.png index 3c6d238..a9b7ca7 100644 Binary files a/public/logo_facture.png and b/public/logo_facture.png differ diff --git a/src/Command/CheckNddCommand.php b/src/Command/CheckNddCommand.php new file mode 100644 index 0000000..bcd2124 --- /dev/null +++ b/src/Command/CheckNddCommand.php @@ -0,0 +1,108 @@ +title('Verification expiration des noms de domaine'); + + $now = new \DateTimeImmutable(); + $limit = $now->modify('+'.self::EXPIRATION_THRESHOLD_DAYS.' days'); + + /** @var Domain[] $domains */ + $domains = $this->em->createQueryBuilder() + ->select('d') + ->from(Domain::class, 'd') + ->where('d.expiredAt IS NOT NULL') + ->andWhere('d.expiredAt <= :limit') + ->orderBy('d.expiredAt', 'ASC') + ->setParameter('limit', $limit) + ->getQuery() + ->getResult(); + + $items = []; + foreach ($domains as $domain) { + $customer = $domain->getCustomer(); + $diff = $now->diff($domain->getExpiredAt()); + $days = (int) $diff->format('%r%a'); + $items[] = [ + 'fqdn' => $domain->getFqdn(), + 'registrar' => $domain->getRegistrar(), + 'customerName' => $customer->getFullName(), + 'customerEmail' => $customer->getEmail(), + 'expiredAt' => $domain->getExpiredAt(), + 'daysLeft' => $days, + 'isExpired' => $days < 0, + ]; + $io->text(sprintf(' %s -> %s (%d j)', $domain->getFqdn(), $customer->getEmail() ?? '-', $days)); + } + + if ([] === $items) { + $subject = 'Nom de domaine - Aucune expiration prochaine'; + $message = 'Aucun nom de domaine en expiration.'; + } else { + $subject = 'Nom de domaine - '.\count($items).' expiration(s) prochaine(s)'; + $message = null; + } + + try { + $html = $this->twig->render('emails/ndd_expiration.html.twig', [ + 'domains' => $items, + 'message' => $message, + 'thresholdDays' => self::EXPIRATION_THRESHOLD_DAYS, + ]); + + $this->mailer->sendEmail( + self::MONITOR_EMAIL, + $subject, + $html, + null, + null, + false, + ); + } catch (\Throwable $e) { + $this->logger->error('CheckNdd: erreur envoi mail: '.$e->getMessage()); + $io->error('Erreur envoi mail : '.$e->getMessage()); + + return Command::FAILURE; + } + + if ([] === $items) { + $io->success('Aucun nom de domaine en expiration. Rapport envoye a '.self::MONITOR_EMAIL); + } else { + $io->success(\count($items).' domaine(s) en expiration. Rapport envoye a '.self::MONITOR_EMAIL); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/TestMailCommand.php b/src/Command/TestMailCommand.php index a75f656..bfd6bf2 100644 --- a/src/Command/TestMailCommand.php +++ b/src/Command/TestMailCommand.php @@ -10,6 +10,9 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Email; use Twig\Environment; #[AsCommand( @@ -29,7 +32,8 @@ class TestMailCommand extends Command { $this ->addArgument('email', InputArgument::REQUIRED, 'Adresse email du destinataire') - ->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Mode d\'envoi (dev ou prod)', 'dev'); + ->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Mode d\'envoi (dev ou prod)', 'dev') + ->addOption('force-dsn', null, InputOption::VALUE_REQUIRED, 'Force un DSN SMTP specifique (ex: ses+smtp://...@default?region=eu-west-3) pour envoyer directement sans passer par le transport local'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -37,6 +41,7 @@ class TestMailCommand extends Command $io = new SymfonyStyle($input, $output); $email = $input->getArgument('email'); $env = $input->getOption('mode'); + $forceDsn = $input->getOption('force-dsn'); $subject = 'prod' === $env ? '[PROD] CRM SITECONSEIL - Email de test production' @@ -51,6 +56,10 @@ class TestMailCommand extends Command 'date' => new \DateTimeImmutable(), ]); + if (null !== $forceDsn) { + return $this->sendViaForceDsn($forceDsn, $email, $subject, $html, $io); + } + $this->mailer->sendEmail( $email, $subject, @@ -64,4 +73,30 @@ class TestMailCommand extends Command return Command::SUCCESS; } + + private function sendViaForceDsn(string $dsn, string $to, string $subject, string $html, SymfonyStyle $io): int + { + $io->text('Force DSN : '.$dsn); + + try { + $transport = Transport::fromDsn($dsn); + $directMailer = new Mailer($transport); + + $email = (new Email()) + ->from('SARL SITECONSEIL ') + ->to($to) + ->subject($subject) + ->html($html); + + $directMailer->send($email); + + $io->success('Email envoye directement via force-dsn a '.$to); + + return Command::SUCCESS; + } catch (\Throwable $e) { + $io->error('Echec envoi via force-dsn : '.$e->getMessage()); + + return Command::FAILURE; + } + } } diff --git a/src/Controller/Admin/AdvertController.php b/src/Controller/Admin/AdvertController.php new file mode 100644 index 0000000..5d76cde --- /dev/null +++ b/src/Controller/Admin/AdvertController.php @@ -0,0 +1,267 @@ + '\d+'], methods: ['POST'])] + public function generatePdf(int $id, KernelInterface $kernel, UrlGeneratorInterface $urlGenerator): 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); + $pdf->generate(); + + $tmpPath = tempnam(sys_get_temp_dir(), 'advert_').'.pdf'; + $pdf->Output('F', $tmpPath); + + $hadOld = null !== $advert->getAdvertFile(); + $uploadDir = $kernel->getProjectDir().'/public/uploads/adverts'; + + if ($hadOld) { + $oldPath = $uploadDir.'/'.$advert->getAdvertFile(); + if (file_exists($oldPath)) { + @unlink($oldPath); + } + $advert->setAdvertFile(null); + } + + $uploadedFile = new UploadedFile( + $tmpPath, + 'avis-'.str_replace('/', '-', $advert->getOrderNumber()->getNumOrder()).'.pdf', + 'application/pdf', + null, + true + ); + + $advert->setAdvertFileUpload($uploadedFile); + $advert->setUpdatedAt(new \DateTimeImmutable()); + + $this->em->flush(); + + @unlink($tmpPath); + + $this->addFlash('success', 'PDF avis '.$advert->getOrderNumber()->getNumOrder().' '.($hadOld ? 'regenere' : 'genere').'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + + #[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 { + $advert = $this->em->getRepository(Advert::class)->find($id); + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable'); + } + + if (null === $advert->getAdvertFile()) { + $this->addFlash('error', 'Le PDF doit etre genere avant l\'envoi.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $advert->getCustomer()?->getId() ?? 0, + 'tab' => 'avis', + ]); + } + + $customer = $advert->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' => 'avis', + ]); + } + + $numOrder = $advert->getOrderNumber()->getNumOrder(); + $paymentUrl = $urlGenerator->generate('app_order_payment', [ + 'numOrder' => $numOrder, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + // PJ : le PDF de l'avis + $attachments = []; + $pdfPath = $projectDir.'/public/uploads/adverts/'.$advert->getAdvertFile(); + if (file_exists($pdfPath)) { + $attachments[] = ['path' => $pdfPath, 'name' => 'avis-paiement-'.str_replace('/', '-', $numOrder).'.pdf']; + } + + $html = $twig->render('emails/advert_send.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'paymentUrl' => $paymentUrl, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Avis de paiement '.$numOrder, + $html, + null, + null, + false, + $attachments, + ); + + $advert->setState(Advert::STATE_SEND); + $this->em->flush(); + + $this->meilisearch->indexAdvert($advert); + + $this->addFlash('success', 'Avis de paiement envoye a '.$customer->getEmail().'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'avis', + ]); + } + + #[Route('/{id}/resend', name: 'resend', requirements: ['id' => '\d+'], methods: ['POST'])] + public function resend( + int $id, + MailerService $mailer, + Environment $twig, + UrlGeneratorInterface $urlGenerator, + #[Autowire('%kernel.project_dir%')] string $projectDir, + ): Response { + $advert = $this->em->getRepository(Advert::class)->find($id); + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable'); + } + + $customer = $advert->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' => 'avis', + ]); + } + + if (null === $advert->getAdvertFile()) { + $this->addFlash('error', 'Le PDF doit exister pour renvoyer l\'avis.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'avis', + ]); + } + + $numOrder = $advert->getOrderNumber()->getNumOrder(); + $paymentUrl = $urlGenerator->generate('app_order_payment', [ + 'numOrder' => $numOrder, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $attachments = []; + $pdfPath = $projectDir.'/public/uploads/adverts/'.$advert->getAdvertFile(); + if (file_exists($pdfPath)) { + $attachments[] = ['path' => $pdfPath, 'name' => 'avis-paiement-'.str_replace('/', '-', $numOrder).'.pdf']; + } + + $html = $twig->render('emails/advert_send.html.twig', [ + 'customer' => $customer, + 'advert' => $advert, + 'paymentUrl' => $paymentUrl, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Rappel - Avis de paiement '.$numOrder, + $html, + null, + null, + false, + $attachments, + ); + + $this->addFlash('success', 'Avis de paiement renvoye a '.$customer->getEmail().'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'avis', + ]); + } + + #[Route('/search/{customerId}', name: 'search', requirements: ['customerId' => '\d+'], methods: ['GET'])] + public function search(int $customerId, \Symfony\Component\HttpFoundation\Request $request): \Symfony\Component\HttpFoundation\JsonResponse + { + $query = trim($request->query->getString('q')); + if ('' === $query) { + return $this->json([]); + } + + $results = $this->meilisearch->searchAdverts($query, 20, $customerId); + + return $this->json($results); + } + + #[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])] + public function cancel(int $id): Response + { + $advert = $this->em->getRepository(Advert::class)->find($id); + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable'); + } + + if (Advert::STATE_CANCEL === $advert->getState()) { + $this->addFlash('error', 'Avis de paiement deja annule.'); + } else { + $advert->setState(Advert::STATE_CANCEL); + + // Delie l'avis du devis pour permettre d'en recrée un nouveau + $devis = $advert->getDevis(); + if (null !== $devis) { + $advert->setDevis(null); + $devis->setAdvert(null); + } + + // Libere le numero + $advert->getOrderNumber()->markAsUnused(); + + $this->em->flush(); + + $this->meilisearch->indexAdvert($advert); + + $this->addFlash('success', 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder().' annule. Le devis peut recevoir un nouvel avis.'); + } + + $customerId = $advert->getCustomer()?->getId(); + + return $customerId + ? $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'avis']) + : $this->redirectToRoute('app_admin_clients_index'); + } +} diff --git a/src/Controller/Admin/ClientsController.php b/src/Controller/Admin/ClientsController.php index c700283..38197c9 100644 --- a/src/Controller/Admin/ClientsController.php +++ b/src/Controller/Admin/ClientsController.php @@ -386,6 +386,8 @@ class ClientsController extends AbstractController $domains = $em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]); $domainsInfo = $this->buildDomainsInfo($domains, $em, $esyMailService, 'ndd' === $tab); $websites = $em->getRepository(\App\Entity\Website::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); + $devisList = $em->getRepository(\App\Entity\Devis::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); + $advertsList = $em->getRepository(\App\Entity\Advert::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']); return $this->render('admin/clients/show.html.twig', [ 'customer' => $customer, @@ -393,6 +395,8 @@ class ClientsController extends AbstractController 'domains' => $domains, 'domainsInfo' => $domainsInfo, 'websites' => $websites, + 'devisList' => $devisList, + 'advertsList' => $advertsList, 'tab' => $tab, ]); } diff --git a/src/Controller/Admin/DevisController.php b/src/Controller/Admin/DevisController.php new file mode 100644 index 0000000..905e2f8 --- /dev/null +++ b/src/Controller/Admin/DevisController.php @@ -0,0 +1,525 @@ + '\d+'], methods: ['GET'])] + public function search(int $customerId, Request $request): Response + { + $query = trim($request->query->getString('q')); + if ('' === $query) { + return $this->json([]); + } + + $results = $this->meilisearch->searchDevis($query, 20, $customerId); + + return $this->json($results); + } + + #[Route('/create/{customerId}', name: 'create', requirements: ['customerId' => '\d+'])] + public function create(int $customerId, Request $request): Response + { + $customer = $this->em->getRepository(Customer::class)->find($customerId); + if (null === $customer) { + throw $this->createNotFoundException('Client introuvable'); + } + + if ($request->isMethod('POST')) { + return $this->handleSave($customer, $request); + } + + return $this->render('admin/devis/create.html.twig', [ + 'customer' => $customer, + 'customerId' => $customerId, + 'numOrder' => $this->orderNumberService->preview(), + 'today' => new \DateTimeImmutable(), + 'quickPrices' => TarificationService::getDefaultTypes(), + ]); + } + + #[Route('/{id}/edit', name: 'edit', requirements: ['id' => '\d+'])] + public function edit(int $id, Request $request): Response + { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + if (Devis::STATE_CANCEL === $devis->getState()) { + $this->addFlash('error', 'Impossible de modifier un devis annule.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $devis->getCustomer()?->getId() ?? 0, + 'tab' => 'devis', + ]); + } + + $customer = $devis->getCustomer(); + if (null === $customer) { + throw $this->createNotFoundException('Client introuvable'); + } + + if ($request->isMethod('POST')) { + return $this->handleSave($customer, $request, $devis); + } + + return $this->render('admin/devis/create.html.twig', [ + 'customer' => $customer, + 'customerId' => $customer->getId(), + 'numOrder' => $devis->getOrderNumber()->getNumOrder(), + 'today' => $devis->getCreatedAt(), + 'quickPrices' => TarificationService::getDefaultTypes(), + 'devis' => $devis, + 'isEdit' => true, + ]); + } + + private function handleSave(Customer $customer, Request $request, ?Devis $devis = null): Response + { + $isEdit = null !== $devis; + + /** @var array $lines */ + $lines = $request->request->all('lines'); + + if ([] === $lines) { + $this->addFlash('error', 'Le devis doit contenir au moins une ligne.'); + + return $isEdit + ? $this->redirectToRoute('app_admin_devis_edit', ['id' => $devis->getId()]) + : $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]); + } + + if (!$isEdit) { + // Creation du devis (OrderNumber genere ou reutilise) + $devis = $this->devisService->create(); + $devis->setCustomer($customer); + $devis->setSubmitterSiteconseilId($this->getUser()?->getId()); + } else { + // Edition : supprimer les lignes existantes (orphanRemoval gere la suppression DB) + foreach ($devis->getLines()->toArray() as $oldLine) { + $devis->removeLine($oldLine); + } + } + + // Calcul total HT + tri par pos front + $totalHt = 0.0; + uasort($lines, static fn ($a, $b) => ((int) ($a['pos'] ?? 0)) <=> ((int) ($b['pos'] ?? 0))); + + $pos = 0; + foreach ($lines as $data) { + $title = trim((string) ($data['title'] ?? '')); + if ('' === $title) { + continue; + } + + $priceHt = (float) str_replace(',', '.', (string) ($data['priceHt'] ?? '0')); + + $line = new DevisLine($devis, $title, number_format($priceHt, 2, '.', ''), $pos); + $description = trim((string) ($data['description'] ?? '')); + if ('' !== $description) { + $line->setDescription($description); + } + + $this->em->persist($line); + $devis->addLine($line); + + $totalHt += $priceHt; + ++$pos; + } + + if (0 === $pos) { + $this->addFlash('error', 'Aucune ligne valide.'); + + return $isEdit + ? $this->redirectToRoute('app_admin_devis_edit', ['id' => $devis->getId()]) + : $this->redirectToRoute('app_admin_devis_create', ['customerId' => $customer->getId()]); + } + + $totalTva = round($totalHt * self::TVA_RATE, 2); + $totalTtc = round($totalHt + $totalTva, 2); + + $devis->setTotalHt(number_format($totalHt, 2, '.', '')); + $devis->setTotalTva(number_format($totalTva, 2, '.', '')); + $devis->setTotalTtc(number_format($totalTtc, 2, '.', '')); + + $this->em->flush(); + + $this->meilisearch->indexDevis($devis); + + $this->addFlash('success', 'Devis '.$devis->getOrderNumber()->getNumOrder().($isEdit ? ' modifie' : ' cree').' avec succes.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'devis', + ]); + } + + #[Route('/{id}/generate-pdf', name: 'generate_pdf', requirements: ['id' => '\d+'], methods: ['POST'])] + public function generatePdf(int $id, KernelInterface $kernel): Response + { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + // Generation du PDF (devis + CGV fusionnees via FPDI) + $pdf = new DevisPdf($kernel, $devis); + $pdf->generate(); + + // Ecriture dans un fichier temporaire + $tmpPath = tempnam(sys_get_temp_dir(), 'devis_').'.pdf'; + $pdf->Output('F', $tmpPath); + + $hadOld = null !== $devis->getUnsignedPdf(); + $uploadDir = $kernel->getProjectDir().'/public/uploads/devis'; + + // Regeneration : supprime l'ancien fichier et libere le filename + if ($hadOld) { + $oldPath = $uploadDir.'/'.$devis->getUnsignedPdf(); + if (file_exists($oldPath)) { + @unlink($oldPath); + } + $devis->setUnsignedPdf(null); + } + + // UploadedFile avec test=true : contourne la verification "is_uploaded_file" + // qui rejetterait un fichier genere serveur-side + $uploadedFile = new UploadedFile( + $tmpPath, + 'devis-'.str_replace('/', '-', $devis->getOrderNumber()->getNumOrder()).'.pdf', + 'application/pdf', + null, + true // test mode + ); + + $devis->setUnsignedPdfFile($uploadedFile); + $devis->setUpdatedAt(new \DateTimeImmutable()); + + $this->em->flush(); + + $this->addFlash('success', 'PDF devis '.$devis->getOrderNumber()->getNumOrder().' '.($hadOld ? 'regenere' : 'genere').'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $devis->getCustomer()?->getId() ?? 0, + 'tab' => 'devis', + ]); + } + + #[Route('/{id}/events', name: 'events', requirements: ['id' => '\d+'], methods: ['GET'])] + public function events(int $id): Response + { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + $submitterId = (int) ($devis->getSubmissionId() ?? '0'); + $events = []; + if ($submitterId > 0) { + $events = $this->em->getRepository(DocusealEvent::class) + ->createQueryBuilder('e') + ->where('e.type = :type') + ->andWhere('e.submitterId = :sid') + ->setParameter('type', 'devis') + ->setParameter('sid', $submitterId) + ->orderBy('e.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + return $this->render('admin/devis/events.html.twig', [ + 'devis' => $devis, + 'events' => $events, + ]); + } + + #[Route('/{id}/send', name: 'send', requirements: ['id' => '\d+'], methods: ['POST'])] + public function send( + int $id, + DocuSealService $docuSeal, + MailerService $mailer, + Environment $twig, + UrlGeneratorInterface $urlGenerator, + ): Response { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + if (null === $devis->getUnsignedPdf()) { + $this->addFlash('error', 'Le PDF du devis doit etre genere avant l\'envoi.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $devis->getCustomer()?->getId() ?? 0, + 'tab' => 'devis', + ]); + } + + $customer = $devis->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' => 'devis', + ]); + } + + // URL de redirection post-signature DocuSeal + $signedRedirectUrl = $urlGenerator->generate('app_devis_process_signed', [ + 'id' => $devis->getId(), + 'hmac' => $devis->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + // Envoi a DocuSeal et recuperation de l'id submitter + $submitterId = $docuSeal->sendDevisForSignature($devis, $signedRedirectUrl); + if (null === $submitterId) { + $this->addFlash('error', 'Echec de l\'envoi a DocuSeal.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'devis', + ]); + } + + $devis->setSubmissionId((string) $submitterId); + $devis->setState(Devis::STATE_SEND); + $this->em->flush(); + + // Envoi du mail au client avec le lien vers /devis/process/{id}/{hmac} + $processUrl = $urlGenerator->generate('app_devis_process', [ + 'id' => $devis->getId(), + 'hmac' => $devis->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $html = $twig->render('emails/devis_to_sign.html.twig', [ + 'customer' => $customer, + 'devis' => $devis, + 'processUrl' => $processUrl, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Votre devis '.$devis->getOrderNumber()->getNumOrder().' est pret a signer', + $html, + ); + + $this->meilisearch->indexDevis($devis); + + $this->addFlash('success', 'Devis envoye a '.$customer->getEmail().' pour signature.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'devis', + ]); + } + + #[Route('/{id}/resend', name: 'resend', requirements: ['id' => '\d+'], methods: ['POST'])] + public function resend( + int $id, + DocuSealService $docuSeal, + MailerService $mailer, + Environment $twig, + UrlGeneratorInterface $urlGenerator, + ): Response { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + $customer = $devis->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' => 'devis', + ]); + } + + if (null === $devis->getUnsignedPdf()) { + $this->addFlash('error', 'Le PDF doit exister pour renvoyer le lien.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'devis', + ]); + } + + $signedRedirectUrl = $urlGenerator->generate('app_devis_process_signed', [ + 'id' => $devis->getId(), + 'hmac' => $devis->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + // Archive l'ancienne submission + cree une nouvelle + $submitterId = $docuSeal->resendDevisSignature($devis, $signedRedirectUrl); + if (null === $submitterId) { + $this->addFlash('error', 'Echec du renvoi a DocuSeal.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'devis', + ]); + } + + $devis->setSubmissionId((string) $submitterId); + $devis->setState(Devis::STATE_SEND); + $this->em->flush(); + + // Renvoi du mail au client + $processUrl = $urlGenerator->generate('app_devis_process', [ + 'id' => $devis->getId(), + 'hmac' => $devis->getHmac(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $html = $twig->render('emails/devis_to_sign.html.twig', [ + 'customer' => $customer, + 'devis' => $devis, + 'processUrl' => $processUrl, + ]); + + $mailer->sendEmail( + $customer->getEmail(), + 'Nouveau lien de signature - Devis '.$devis->getOrderNumber()->getNumOrder(), + $html, + ); + + $this->addFlash('success', 'Nouveau lien de signature envoye a '.$customer->getEmail().'.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $customer->getId(), + 'tab' => 'devis', + ]); + } + + #[Route('/{id}/create-advert', name: 'create_advert', requirements: ['id' => '\d+'], methods: ['POST'])] + public function createAdvert(int $id, AdvertService $advertService): Response + { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + if (Devis::STATE_ACCEPTED !== $devis->getState()) { + $this->addFlash('error', 'Le devis doit etre signe (accepted) pour creer un avis de paiement.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $devis->getCustomer()?->getId() ?? 0, + 'tab' => 'devis', + ]); + } + + if (null !== $devis->getAdvert()) { + $this->addFlash('error', 'Un avis de paiement existe deja pour ce devis.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $devis->getCustomer()?->getId() ?? 0, + 'tab' => 'devis', + ]); + } + + // Creation de l'avis de paiement avec le meme OrderNumber + $advert = $advertService->createFromDevis($devis); + + // Copie du client + $advert->setCustomer($devis->getCustomer()); + + // Copie des totaux + $advert->setTotalHt($devis->getTotalHt()); + $advert->setTotalTva($devis->getTotalTva()); + $advert->setTotalTtc($devis->getTotalTtc()); + + // Copie des lignes + foreach ($devis->getLines() as $devisLine) { + $advertLine = new AdvertLine( + $advert, + $devisLine->getTitle(), + $devisLine->getPriceHt(), + $devisLine->getPos(), + ); + if (null !== $devisLine->getDescription()) { + $advertLine->setDescription($devisLine->getDescription()); + } + $this->em->persist($advertLine); + $advert->addLine($advertLine); + } + + $this->em->flush(); + + $this->meilisearch->indexAdvert($advert); + $this->meilisearch->indexDevis($devis); + + $this->addFlash('success', 'Avis de paiement '.$advert->getOrderNumber()->getNumOrder().' cree a partir du devis.'); + + return $this->redirectToRoute('app_admin_clients_show', [ + 'id' => $devis->getCustomer()?->getId() ?? 0, + 'tab' => 'devis', + ]); + } + + #[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])] + public function cancel(int $id): Response + { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable'); + } + + if (Devis::STATE_CANCEL === $devis->getState()) { + $this->addFlash('error', 'Devis deja annule.'); + } else { + $devis->setState(Devis::STATE_CANCEL); + // Libere le numero pour qu'il soit reutilise + $devis->getOrderNumber()->markAsUnused(); + $this->em->flush(); + + $this->meilisearch->indexDevis($devis); + + $this->addFlash('success', 'Devis '.$devis->getOrderNumber()->getNumOrder().' annule. Le numero est libere.'); + } + + $customerId = $devis->getCustomer()?->getId(); + + return $customerId + ? $this->redirectToRoute('app_admin_clients_show', ['id' => $customerId, 'tab' => 'devis']) + : $this->redirectToRoute('app_admin_clients_index'); + } +} diff --git a/src/Controller/Admin/SyncController.php b/src/Controller/Admin/SyncController.php index ce33e56..efd919a 100644 --- a/src/Controller/Admin/SyncController.php +++ b/src/Controller/Admin/SyncController.php @@ -2,7 +2,9 @@ namespace App\Controller\Admin; +use App\Entity\Advert; use App\Entity\CustomerContact; +use App\Entity\Devis; use App\Entity\Domain; use App\Entity\StripeWebhookSecret; use App\Entity\Website; @@ -64,6 +66,8 @@ class SyncController extends AbstractController 'totalContacts' => $em->getRepository(CustomerContact::class)->count([]), 'totalDomains' => $em->getRepository(Domain::class)->count([]), 'totalWebsites' => $em->getRepository(Website::class)->count([]), + 'totalDevis' => $em->getRepository(Devis::class)->count([]), + 'totalAdverts' => $em->getRepository(Advert::class)->count([]), 'webhookSecrets' => $webhookSecrets, ]); } @@ -136,6 +140,40 @@ class SyncController extends AbstractController return $this->redirectToRoute('app_admin_sync_index'); } + #[Route('/devis', name: 'devis', methods: ['POST'])] + public function syncDevis(EntityManagerInterface $em, MeilisearchService $meilisearch): Response + { + try { + $meilisearch->setupIndexes(); + $items = $em->getRepository(Devis::class)->findAll(); + foreach ($items as $item) { + $meilisearch->indexDevis($item); + } + $this->addFlash('success', \count($items).' devis synchronise(s) dans Meilisearch.'); + } catch (\Throwable $e) { + $this->addFlash('error', 'Erreur sync devis : '.$e->getMessage()); + } + + return $this->redirectToRoute('app_admin_sync_index'); + } + + #[Route('/adverts', name: 'adverts', methods: ['POST'])] + public function syncAdverts(EntityManagerInterface $em, MeilisearchService $meilisearch): Response + { + try { + $meilisearch->setupIndexes(); + $items = $em->getRepository(Advert::class)->findAll(); + foreach ($items as $item) { + $meilisearch->indexAdvert($item); + } + $this->addFlash('success', \count($items).' avis de paiement synchronise(s) dans Meilisearch.'); + } catch (\Throwable $e) { + $this->addFlash('error', 'Erreur sync avis : '.$e->getMessage()); + } + + return $this->redirectToRoute('app_admin_sync_index'); + } + #[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])] public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response { diff --git a/src/Controller/DevisPdfController.php b/src/Controller/DevisPdfController.php index 254f254..812cf71 100644 --- a/src/Controller/DevisPdfController.php +++ b/src/Controller/DevisPdfController.php @@ -45,13 +45,13 @@ class DevisPdfController extends AbstractController throw $this->createNotFoundException('Document non disponible.'); } - $path = $projectDir.'/var/uploads/devis/'.$filename; + $path = $projectDir.'/public/uploads/devis/'.$filename; if (!file_exists($path)) { throw $this->createNotFoundException('Fichier introuvable.'); } - $downloadName = $type.'-'.$devis->getOrderNumber()->getNumOrder().'.pdf'; + $downloadName = $type.'-'.str_replace(['/', '\\'], '-', $devis->getOrderNumber()->getNumOrder()).'.pdf'; $response = new BinaryFileResponse($path); $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $downloadName); diff --git a/src/Controller/DevisProcessController.php b/src/Controller/DevisProcessController.php new file mode 100644 index 0000000..82bac6b --- /dev/null +++ b/src/Controller/DevisProcessController.php @@ -0,0 +1,118 @@ + '\d+'], methods: ['GET'])] + public function show(int $id, string $hmac): Response + { + $devis = $this->loadAndCheck($id, $hmac); + + // Redirige vers les pages dediees si le devis est deja signe/refuse + if (Devis::STATE_ACCEPTED === $devis->getState()) { + return $this->render('devis/signed.html.twig', [ + 'devis' => $devis, + 'customer' => $devis->getCustomer(), + ]); + } + + if (Devis::STATE_REFUSED === $devis->getState()) { + return $this->render('devis/refused.html.twig', [ + 'devis' => $devis, + 'customer' => $devis->getCustomer(), + ]); + } + + return $this->render('devis/process.html.twig', [ + 'devis' => $devis, + 'customer' => $devis->getCustomer(), + ]); + } + + #[Route('/devis/process/{id}/{hmac}/sign', name: 'app_devis_process_sign', requirements: ['id' => '\d+'], methods: ['GET'])] + public function sign(int $id, string $hmac): Response + { + $devis = $this->loadAndCheck($id, $hmac); + + $submitterId = (int) $devis->getSubmissionId(); + if ($submitterId <= 0) { + throw $this->createNotFoundException('Devis non envoye pour signature.'); + } + + $slug = $this->docuSeal->getSubmitterSlug($submitterId); + if (null === $slug) { + throw $this->createNotFoundException('Lien de signature introuvable.'); + } + + return $this->redirect(rtrim($this->docuSealUrl, '/').'/s/'.$slug); + } + + /** + * Appele par DocuSeal en redirect apres signature du client. + * Marque le devis comme accepted et affiche la page de confirmation. + */ + #[Route('/devis/process/{id}/{hmac}/signed', name: 'app_devis_process_signed', requirements: ['id' => '\d+'], methods: ['GET'])] + public function signed(int $id, string $hmac): Response + { + $devis = $this->loadAndCheck($id, $hmac); + + if (Devis::STATE_ACCEPTED !== $devis->getState()) { + $devis->setState(Devis::STATE_ACCEPTED); + $this->em->flush(); + } + + return $this->render('devis/signed.html.twig', [ + 'devis' => $devis, + 'customer' => $devis->getCustomer(), + ]); + } + + #[Route('/devis/process/{id}/{hmac}/refuse', name: 'app_devis_process_refuse', requirements: ['id' => '\d+'], methods: ['POST'])] + public function refuse(int $id, string $hmac, \Symfony\Component\HttpFoundation\Request $request): Response + { + $devis = $this->loadAndCheck($id, $hmac); + + $reason = trim($request->request->getString('reason')); + + $devis->setState(Devis::STATE_REFUSED); + if ('' !== $reason) { + $devis->setRaisonMessage($reason); + } + $this->em->flush(); + + return $this->render('devis/refused.html.twig', [ + 'devis' => $devis, + 'customer' => $devis->getCustomer(), + ]); + } + + private function loadAndCheck(int $id, string $hmac): Devis + { + $devis = $this->em->getRepository(Devis::class)->find($id); + if (null === $devis) { + throw $this->createNotFoundException('Devis introuvable.'); + } + + if (!hash_equals($devis->getHmac(), $hmac)) { + throw $this->createAccessDeniedException('Lien invalide.'); + } + + return $devis; + } +} diff --git a/src/Controller/OrderPaymentController.php b/src/Controller/OrderPaymentController.php new file mode 100644 index 0000000..8c2ef6e --- /dev/null +++ b/src/Controller/OrderPaymentController.php @@ -0,0 +1,34 @@ + '.+'])] + public function index(string $numOrder, EntityManagerInterface $em): Response + { + $advert = $em->createQueryBuilder() + ->select('a') + ->from(Advert::class, 'a') + ->join('a.orderNumber', 'o') + ->where('o.numOrder = :num') + ->setParameter('num', $numOrder) + ->getQuery() + ->getOneOrNullResult(); + + if (null === $advert) { + throw $this->createNotFoundException('Avis de paiement introuvable.'); + } + + return $this->render('order/payment.html.twig', [ + 'advert' => $advert, + 'customer' => $advert->getCustomer(), + ]); + } +} diff --git a/src/Controller/WebhookDocuSealController.php b/src/Controller/WebhookDocuSealController.php index a84da80..85a89b1 100644 --- a/src/Controller/WebhookDocuSealController.php +++ b/src/Controller/WebhookDocuSealController.php @@ -3,7 +3,11 @@ namespace App\Controller; use App\Entity\Attestation; +use App\Entity\Devis; +use App\Entity\DocusealEvent; use App\Repository\AttestationRepository; +use App\Repository\DevisRepository; +use App\Service\DocuSealService; use App\Service\MailerService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -17,11 +21,14 @@ use Twig\Environment; class WebhookDocuSealController extends AbstractController { private const ATTESTATION_NOT_FOUND = 'Attestation not found'; + private const MONITOR_EMAIL = 'monitor@siteconseil.fr'; #[Route('/webhooks/docuseal', name: 'app_webhook_docuseal', methods: ['POST'])] public function __invoke( Request $request, AttestationRepository $attestationRepository, + DevisRepository $devisRepository, + DocuSealService $docuSealService, MailerService $mailer, EntityManagerInterface $em, Environment $twig, @@ -37,9 +44,27 @@ class WebhookDocuSealController extends AbstractController $eventType = $payload['event_type'] ?? ''; $data = $payload['data'] ?? []; $submitterId = $data['id'] ?? null; + $submissionId = $data['submission_id'] ?? null; $metadata = $data['metadata'] ?? []; $docType = $metadata['doc_type'] ?? null; + // Log de l'evenement pour suivi temps reel (tous types : devis, attestation, etc.) + if ('' !== $eventType && null !== $docType) { + $em->persist(new DocusealEvent( + $docType, + $eventType, + null !== $submissionId ? (int) $submissionId : null, + null !== $submitterId ? (int) $submitterId : null, + json_encode($payload), + )); + $em->flush(); + } + + // Dispatch par type de document + if ('devis' === $docType) { + return $this->handleDevisEvent($eventType, $data, $metadata, $devisRepository, $docuSealService, $mailer, $twig, $em, $projectDir); + } + $this->syncSubmitterIdFromMetadata($docType, $submitterId, $metadata, $attestationRepository, $em); if ('attestation' !== $docType && null === $this->findAttestation($submitterId, $attestationRepository)) { @@ -55,6 +80,183 @@ class WebhookDocuSealController extends AbstractController }; } + /** + * @param array $data + * @param array $metadata + */ + private function handleDevisEvent( + string $eventType, + array $data, + array $metadata, + DevisRepository $devisRepository, + DocuSealService $docuSealService, + MailerService $mailer, + Environment $twig, + EntityManagerInterface $em, + string $projectDir, + ): JsonResponse { + // Resolution du devis via metadata.devis_id en priorite, sinon via submitter id + $devis = null; + $devisId = $metadata['devis_id'] ?? null; + if (null !== $devisId) { + $devis = $devisRepository->find((int) $devisId); + } + + if (null === $devis) { + $submitterId = $data['id'] ?? null; + if (null !== $submitterId) { + $devis = $devisRepository->findOneBy(['submissionId' => (string) $submitterId]); + } + } + + if (null === $devis) { + return new JsonResponse(['status' => 'ignored', 'reason' => 'devis not found'], Response::HTTP_NOT_FOUND); + } + + switch ($eventType) { + case 'form.viewed': + case 'form.started': + // Juste tracer l'evenement (deja fait en amont via DocusealEvent) + return new JsonResponse(['status' => 'ok', 'event' => $eventType, 'devis' => $devis->getOrderNumber()->getNumOrder()]); + + case 'form.completed': + $devis->setState(Devis::STATE_ACCEPTED); + $devis->setUpdatedAt(new \DateTimeImmutable()); + $em->flush(); + // Recupere le PDF signe et le certificat d'audit depuis DocuSeal (via Vich) + $docuSealService->downloadSignedDevis($devis); + + // Notifications email : client + admin + $this->sendDevisSignedNotifications($devis, $mailer, $twig, $projectDir); + + return new JsonResponse(['status' => 'ok', 'event' => 'completed', 'devis' => $devis->getOrderNumber()->getNumOrder()]); + + case 'form.declined': + $devis->setState(Devis::STATE_REFUSED); + $devis->setUpdatedAt(new \DateTimeImmutable()); + $reason = isset($data['decline_reason']) && '' !== $data['decline_reason'] + ? (string) $data['decline_reason'] + : null; + if (null !== $reason) { + $devis->setRaisonMessage($reason); + } + $em->flush(); + + // Notifications email : client + admin + $this->sendDevisRefusedNotifications($devis, $reason, $mailer, $twig); + + return new JsonResponse(['status' => 'ok', 'event' => 'declined', 'devis' => $devis->getOrderNumber()->getNumOrder()]); + + default: + return new JsonResponse(['status' => 'ignored', 'event' => $eventType]); + } + } + + /** + * Envoie 2 emails : "Vous avez signe votre devis" au client + notification admin avec PDFs en piece jointe. + */ + private function sendDevisSignedNotifications(Devis $devis, MailerService $mailer, Environment $twig, string $projectDir): void + { + $customer = $devis->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + return; + } + + $numOrder = $devis->getOrderNumber()->getNumOrder(); + + // Mail client : confirmation signature + try { + $mailer->sendEmail( + $customer->getEmail(), + 'Vous avez signe votre devis '.$numOrder, + $twig->render('emails/devis_signed_client.html.twig', [ + 'customer' => $customer, + 'devis' => $devis, + ]), + ); + } catch (\Throwable) { + // silencieux + } + + // Mail admin : notification avec PDFs en piece jointe + $attachments = []; + $uploadDir = $projectDir.'/public/uploads/devis'; + + if (null !== $devis->getSignedPdf()) { + $signedPath = $uploadDir.'/'.$devis->getSignedPdf(); + if (file_exists($signedPath)) { + $attachments[] = ['path' => $signedPath, 'name' => 'devis-signe-'.str_replace('/', '-', $numOrder).'.pdf']; + } + } + if (null !== $devis->getAuditPdf()) { + $auditPath = $uploadDir.'/'.$devis->getAuditPdf(); + if (file_exists($auditPath)) { + $attachments[] = ['path' => $auditPath, 'name' => 'audit-'.str_replace('/', '-', $numOrder).'.pdf']; + } + } + + try { + $mailer->sendEmail( + self::MONITOR_EMAIL, + 'Devis '.$numOrder.' signe par '.$customer->getFullName(), + $twig->render('emails/devis_signed_admin.html.twig', [ + 'customer' => $customer, + 'devis' => $devis, + ]), + null, + null, + false, + $attachments, + ); + } catch (\Throwable) { + // silencieux + } + } + + /** + * Envoie 2 emails : "Vous avez refuse votre devis" au client + notification admin du refus. + */ + private function sendDevisRefusedNotifications(Devis $devis, ?string $reason, MailerService $mailer, Environment $twig): void + { + $customer = $devis->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + return; + } + + $numOrder = $devis->getOrderNumber()->getNumOrder(); + + try { + $mailer->sendEmail( + $customer->getEmail(), + 'Vous avez refuse le devis '.$numOrder, + $twig->render('emails/devis_refused_client.html.twig', [ + 'customer' => $customer, + 'devis' => $devis, + 'reason' => $reason, + ]), + ); + } catch (\Throwable) { + // silencieux + } + + try { + $mailer->sendEmail( + self::MONITOR_EMAIL, + 'Devis '.$numOrder.' refuse par '.$customer->getFullName(), + $twig->render('emails/devis_refused_admin.html.twig', [ + 'customer' => $customer, + 'devis' => $devis, + 'reason' => $reason, + ]), + null, + null, + false, + ); + } catch (\Throwable) { + // silencieux + } + } + /** * @return array|Response */ diff --git a/src/Entity/Advert.php b/src/Entity/Advert.php index ca9754b..aaba93d 100644 --- a/src/Entity/Advert.php +++ b/src/Entity/Advert.php @@ -6,10 +6,19 @@ use App\Repository\AdvertRepository; 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: AdvertRepository::class)] +#[Vich\Uploadable] class Advert { + public const STATE_CREATED = 'created'; + public const STATE_SEND = 'send'; + public const STATE_ACCEPTED = 'accepted'; + public const STATE_REFUSED = 'refused'; + public const STATE_CANCEL = 'cancel'; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -19,25 +28,62 @@ class Advert #[ORM\JoinColumn(nullable: false)] private OrderNumber $orderNumber; - #[ORM\ManyToOne(targetEntity: Devis::class, inversedBy: 'adverts')] + #[ORM\OneToOne(targetEntity: Devis::class, inversedBy: 'advert')] #[ORM\JoinColumn(nullable: true)] private ?Devis $devis = null; + #[ORM\ManyToOne(targetEntity: Customer::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?Customer $customer = null; + #[ORM\Column(length: 128)] private string $hmac; + #[ORM\Column(length: 20, options: ['default' => 'created'])] + private string $state = self::STATE_CREATED; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $raisonMessage = null; + + #[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(length: 255, nullable: true)] + private ?string $submissionId = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $advertFile = null; + + #[Vich\UploadableField(mapping: 'advert_pdf', fileNameProperty: 'advertFile')] + private ?File $advertFileUpload = null; + #[ORM\Column] private \DateTimeImmutable $createdAt; + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + /** @var Collection */ #[ORM\OneToMany(targetEntity: Facture::class, mappedBy: 'advert')] private Collection $factures; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: AdvertLine::class, mappedBy: 'advert', 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->factures = new ArrayCollection(); + $this->lines = new ArrayCollection(); $this->hmac = $this->generateHmac($hmacSecret); } @@ -61,22 +107,149 @@ class Advert $this->devis = $devis; } + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function setCustomer(?Customer $customer): void + { + $this->customer = $customer; + } + public function getHmac(): string { return $this->hmac; } + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): void + { + $this->state = $state; + } + + public function getRaisonMessage(): ?string + { + return $this->raisonMessage; + } + + public function setRaisonMessage(?string $raisonMessage): void + { + $this->raisonMessage = $raisonMessage; + } + + 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 getSubmissionId(): ?string + { + return $this->submissionId; + } + + public function setSubmissionId(?string $submissionId): void + { + $this->submissionId = $submissionId; + } + + public function getAdvertFile(): ?string + { + return $this->advertFile; + } + + public function setAdvertFile(?string $advertFile): void + { + $this->advertFile = $advertFile; + } + + public function getAdvertFileUpload(): ?File + { + return $this->advertFileUpload; + } + + public function setAdvertFileUpload(?File $advertFileUpload): void + { + $this->advertFileUpload = $advertFileUpload; + if (null !== $advertFileUpload) { + $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 */ public function getFactures(): Collection { return $this->factures; } + /** @return Collection */ + public function getLines(): Collection + { + return $this->lines; + } + + public function addLine(AdvertLine $line): static + { + if (!$this->lines->contains($line)) { + $this->lines->add($line); + } + + return $this; + } + + public function removeLine(AdvertLine $line): static + { + $this->lines->removeElement($line); + + return $this; + } + public function verifyHmac(string $hmacSecret): bool { return hash_equals($this->hmac, $this->generateHmac($hmacSecret)); diff --git a/src/Entity/AdvertLine.php b/src/Entity/AdvertLine.php new file mode 100644 index 0000000..9dcbe13 --- /dev/null +++ b/src/Entity/AdvertLine.php @@ -0,0 +1,96 @@ + '0.00'])] + private string $priceHt = '0.00'; + + public function __construct(Advert $advert, string $title, string $priceHt = '0.00', int $pos = 0) + { + $this->advert = $advert; + $this->title = $title; + $this->priceHt = $priceHt; + $this->pos = $pos; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAdvert(): Advert + { + return $this->advert; + } + + 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; + } +} diff --git a/src/Entity/Devis.php b/src/Entity/Devis.php index 8ba51cb..375aa2c 100644 --- a/src/Entity/Devis.php +++ b/src/Entity/Devis.php @@ -7,7 +7,7 @@ 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\Annotation as Vich; +use Vich\UploaderBundle\Mapping\Attribute as Vich; #[ORM\Entity(repositoryClass: DevisRepository::class)] #[Vich\Uploadable] @@ -28,6 +28,10 @@ class Devis #[ORM\JoinColumn(nullable: false)] private OrderNumber $orderNumber; + #[ORM\ManyToOne(targetEntity: Customer::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + private ?Customer $customer = null; + #[ORM\Column(length: 128)] private string $hmac; @@ -52,6 +56,9 @@ class Devis #[ORM\Column(nullable: true)] private ?int $submitterCustomerId = null; + #[ORM\Column(length: 255, nullable: true)] + private ?string $submissionId = null; + #[ORM\Column(length: 255, nullable: true)] private ?string $unsignedPdf = null; @@ -76,15 +83,19 @@ class Devis #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $updatedAt = null; - /** @var Collection */ - #[ORM\OneToMany(targetEntity: Advert::class, mappedBy: 'devis')] - private Collection $adverts; + #[ORM\OneToOne(targetEntity: Advert::class, mappedBy: 'devis')] + private ?Advert $advert = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'devis', 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->adverts = new ArrayCollection(); + $this->lines = new ArrayCollection(); $this->hmac = $this->generateHmac($hmacSecret); } @@ -98,6 +109,16 @@ class Devis return $this->orderNumber; } + public function getCustomer(): ?Customer + { + return $this->customer; + } + + public function setCustomer(?Customer $customer): void + { + $this->customer = $customer; + } + public function getHmac(): string { return $this->hmac; @@ -173,6 +194,16 @@ class Devis $this->submitterCustomerId = $submitterCustomerId; } + public function getSubmissionId(): ?string + { + return $this->submissionId; + } + + public function setSubmissionId(?string $submissionId): void + { + $this->submissionId = $submissionId; + } + public function getUnsignedPdf(): ?string { return $this->unsignedPdf; @@ -252,10 +283,43 @@ class Devis return $this->updatedAt; } - /** @return Collection */ - public function getAdverts(): Collection + public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static { - return $this->adverts; + $this->updatedAt = $updatedAt; + + return $this; + } + + public function getAdvert(): ?Advert + { + return $this->advert; + } + + public function setAdvert(?Advert $advert): void + { + $this->advert = $advert; + } + + /** @return Collection */ + public function getLines(): Collection + { + return $this->lines; + } + + public function addLine(DevisLine $line): static + { + if (!$this->lines->contains($line)) { + $this->lines->add($line); + } + + return $this; + } + + public function removeLine(DevisLine $line): static + { + $this->lines->removeElement($line); + + return $this; } public function verifyHmac(string $hmacSecret): bool diff --git a/src/Entity/DevisLine.php b/src/Entity/DevisLine.php new file mode 100644 index 0000000..62b3a6d --- /dev/null +++ b/src/Entity/DevisLine.php @@ -0,0 +1,96 @@ + '0.00'])] + private string $priceHt = '0.00'; + + public function __construct(Devis $devis, string $title, string $priceHt = '0.00', int $pos = 0) + { + $this->devis = $devis; + $this->title = $title; + $this->priceHt = $priceHt; + $this->pos = $pos; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getDevis(): Devis + { + return $this->devis; + } + + 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; + } +} diff --git a/src/Entity/DocusealEvent.php b/src/Entity/DocusealEvent.php new file mode 100644 index 0000000..bdb3d64 --- /dev/null +++ b/src/Entity/DocusealEvent.php @@ -0,0 +1,80 @@ +type = $type; + $this->eventType = $eventType; + $this->submissionId = $submissionId; + $this->submitterId = $submitterId; + $this->payload = $payload; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): string + { + return $this->type; + } + + public function getEventType(): string + { + return $this->eventType; + } + + public function getSubmissionId(): ?int + { + return $this->submissionId; + } + + public function getSubmitterId(): ?int + { + return $this->submitterId; + } + + public function getPayload(): ?string + { + return $this->payload; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/src/Entity/OrderNumber.php b/src/Entity/OrderNumber.php index 7de2962..c9c4e18 100644 --- a/src/Entity/OrderNumber.php +++ b/src/Entity/OrderNumber.php @@ -52,4 +52,9 @@ class OrderNumber { $this->isUsed = true; } + + public function markAsUnused(): void + { + $this->isUsed = false; + } } diff --git a/src/Service/DocuSealService.php b/src/Service/DocuSealService.php index e741ce7..7b65f0e 100644 --- a/src/Service/DocuSealService.php +++ b/src/Service/DocuSealService.php @@ -3,9 +3,12 @@ namespace App\Service; use App\Entity\Attestation; +use App\Entity\Devis; use Doctrine\ORM\EntityManagerInterface; use Docuseal\Api; +use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\HttpFoundation\File\UploadedFile; class DocuSealService { @@ -13,6 +16,7 @@ class DocuSealService public function __construct( private EntityManagerInterface $em, + private LoggerInterface $logger, #[Autowire(env: 'DOCUSEAL_URL')] string $baseUrl, #[Autowire(env: 'DOCUSEAL_API')] string $apiKey, #[Autowire('%kernel.project_dir%')] private string $projectDir, @@ -75,6 +79,202 @@ class DocuSealService return $success; } + /** + * Envoie un devis a DocuSeal pour signature par le client (First Party). + * Retourne l'id du submitter client pour enregistrement en base. + */ + public function sendDevisForSignature(Devis $devis, ?string $completedRedirectUrl = null): ?int + { + $customer = $devis->getCustomer(); + if (null === $customer || null === $customer->getEmail()) { + $this->logger->error('DocuSeal devis: customer ou email manquant (devis '.$devis->getId().')'); + + return null; + } + + $pdfFilename = $devis->getUnsignedPdf(); + if (null === $pdfFilename) { + $this->logger->error('DocuSeal devis: unsignedPdf null (devis '.$devis->getId().')'); + + return null; + } + + $pdfPath = $this->projectDir.'/public/uploads/devis/'.$pdfFilename; + if (!file_exists($pdfPath)) { + $this->logger->error('DocuSeal devis: fichier PDF introuvable '.$pdfPath); + + return null; + } + + try { + $pdfBase64 = base64_encode(file_get_contents($pdfPath)); + $numOrder = $devis->getOrderNumber()->getNumOrder(); + + $submitter = [ + 'email' => $customer->getEmail(), + 'name' => $customer->getFullName(), + 'role' => 'First Party', + 'send_email' => false, + 'metadata' => [ + 'doc_type' => 'devis', + 'num_order' => $numOrder, + 'devis_id' => $devis->getId(), + ], + ]; + + if (null !== $completedRedirectUrl) { + $submitter['completed_redirect_url'] = $completedRedirectUrl; + } + + $result = $this->api->createSubmissionFromPdf([ + 'name' => 'Devis '.$numOrder, + 'send_email' => false, + 'flatten' => true, + 'documents' => [ + [ + 'name' => 'devis-'.str_replace('/', '-', $numOrder).'.pdf', + 'file' => 'data:application/pdf;base64,'.$pdfBase64, + ], + ], + 'submitters' => [$submitter], + ]); + + $this->logger->info('DocuSeal devis: reponse API', ['result' => $result]); + + return $result['submitters'][0]['id'] ?? null; + } catch (\Throwable $e) { + $this->logger->error('DocuSeal devis: exception API: '.$e->getMessage(), [ + 'exception' => $e, + 'devis_id' => $devis->getId(), + ]); + + return null; + } + } + + /** + * Annule la submission actuelle du devis dans DocuSeal puis en cree une nouvelle. + * Retourne le nouvel id submitter. + */ + public function resendDevisSignature(Devis $devis, ?string $completedRedirectUrl = null): ?int + { + $oldSubmitterId = (int) ($devis->getSubmissionId() ?? '0'); + if ($oldSubmitterId > 0) { + try { + $submitter = $this->api->getSubmitter($oldSubmitterId); + $submissionId = $submitter['submission_id'] ?? null; + if (null !== $submissionId) { + $this->api->archiveSubmission($submissionId); + $this->logger->info('DocuSeal devis: ancienne submission archivee', [ + 'submission_id' => $submissionId, + 'devis_id' => $devis->getId(), + ]); + } + } catch (\Throwable $e) { + $this->logger->warning('DocuSeal devis: echec archive ancienne submission: '.$e->getMessage()); + } + } + + return $this->sendDevisForSignature($devis, $completedRedirectUrl); + } + + /** + * Recupere le slug du submitter DocuSeal pour construire l'URL de signature. + */ + public function getSubmitterSlug(int $submitterId): ?string + { + try { + $submitter = $this->api->getSubmitter($submitterId); + + return $submitter['slug'] ?? null; + } catch (\Throwable) { + return null; + } + } + + /** + * Telecharge et sauvegarde via Vich le PDF signe et le certificat d'audit depuis DocuSeal. + */ + public function downloadSignedDevis(Devis $devis): bool + { + if (null === $devis->getSubmissionId()) { + return false; + } + + $submitterId = (int) $devis->getSubmissionId(); + if ($submitterId <= 0) { + return false; + } + + try { + $submitter = $this->api->getSubmitter($submitterId); + $documents = $submitter['documents'] ?? []; + + if ([] === $documents) { + return false; + } + + $pdfUrl = $documents[0]['url'] ?? null; + if (null === $pdfUrl) { + return false; + } + + $content = @file_get_contents($pdfUrl); + if (false === $content || !str_starts_with($content, '%PDF')) { + return false; + } + + $numOrder = str_replace('/', '-', $devis->getOrderNumber()->getNumOrder()); + + // Sauvegarde du PDF signe via Vich (UploadedFile test mode) + $tmpSigned = tempnam(sys_get_temp_dir(), 'signed_').'.pdf'; + file_put_contents($tmpSigned, $content); + $devis->setSignedPdfFile(new UploadedFile( + $tmpSigned, + 'signed-'.$numOrder.'.pdf', + 'application/pdf', + null, + true + )); + + // Certificat d'audit + $auditUrl = $submitter['audit_log_url'] ?? null; + $tmpAudit = null; + if (null !== $auditUrl) { + $auditContent = @file_get_contents($auditUrl); + if (false !== $auditContent) { + $tmpAudit = tempnam(sys_get_temp_dir(), 'audit_').'.pdf'; + file_put_contents($tmpAudit, $auditContent); + $devis->setAuditPdfFile(new UploadedFile( + $tmpAudit, + 'audit-'.$numOrder.'.pdf', + 'application/pdf', + null, + true + )); + } + } + + $devis->setUpdatedAt(new \DateTimeImmutable()); + $this->em->flush(); + + // Nettoyage des fichiers temporaires apres traitement Vich + @unlink($tmpSigned); + if (null !== $tmpAudit) { + @unlink($tmpAudit); + } + + return true; + } catch (\Throwable $e) { + $this->logger->error('DocuSeal: erreur telechargement devis signe: '.$e->getMessage(), [ + 'devis_id' => $devis->getId(), + 'exception' => $e, + ]); + + return false; + } + } + private function getLogoBase64(): string { $logoPath = $this->projectDir.'/public/logo_facture.png'; diff --git a/src/Service/MailerService.php b/src/Service/MailerService.php index a7f1d5d..87af5f4 100644 --- a/src/Service/MailerService.php +++ b/src/Service/MailerService.php @@ -111,17 +111,33 @@ class MailerService ], UrlGeneratorInterface::ABSOLUTE_URL); $html = str_replace('__DNS_REPORT_URL__', $dnsReportUrl, $html); + // Injection du bloc liste des pieces jointes (hors .asc, .p7z, smime) + if ($attachments) { + $html = $this->injectAttachmentsList($html, $attachments); + } + $email->html($html); $tracking = new EmailTracking($messageId, $to, $subject, $html, $attachments); $this->em->persist($tracking); $this->em->flush(); + // Ajout automatique du fichier VCF (fiche contact SITECONSEIL) + $vcfPath = $this->generateVcf(); + if (null !== $vcfPath) { + $email->attachFromPath($vcfPath, 'SARL-SITECONSEIL.vcf', 'text/vcard'); + } + if ($canUnsubscribe) { $this->addUnsubscribeHeaders($email, $to); } $this->send($email); + + // Nettoyage du fichier VCF temporaire + if (null !== $vcfPath) { + @unlink($vcfPath); + } } private function isWhitelisted(string $email): bool @@ -129,6 +145,113 @@ class MailerService return strtolower(trim($email)) === strtolower($this->adminEmail); } + /** + * Injecte un bloc HTML listant les pieces jointes dans le corps du mail, + * juste avant le footer dark (#111827). Exclut .asc, .p7z et smime. + * + * @param array $attachments + */ + private function injectAttachmentsList(string $html, array $attachments): string + { + $excluded = ['.asc', '.p7z']; + $filtered = []; + + foreach ($attachments as $a) { + $name = $a['name'] ?? basename($a['path']); + $path = $a['path'] ?? ''; + $ext = strtolower(pathinfo($name, \PATHINFO_EXTENSION)); + if (\in_array('.'.$ext, $excluded, true) || str_contains(strtolower($name), 'smime')) { + continue; + } + $size = file_exists($path) ? filesize($path) : 0; + $filtered[] = ['name' => $name, 'size' => $size]; + } + + if ([] === $filtered) { + return $html; + } + + $items = ''; + foreach ($filtered as $f) { + $sizeStr = $this->formatFileSize($f['size']); + $items .= '' + .'' + .'' + .'' + .'' + .'
📎' + .'

'.htmlspecialchars($f['name'], \ENT_QUOTES, 'UTF-8').'

' + .'

Piece jointe ('.$sizeStr.')

' + .'
' + .'' + .''; + } + + $block = '' + .'

Pieces jointes

' + .'' + .$items + .'
' + .''; + + // Injecte avant le footer dark + $marker = '= 1048576) { + return number_format($bytes / 1048576, 1, ',', ' ').' Mo'; + } + if ($bytes >= 1024) { + return number_format($bytes / 1024, 0, ',', ' ').' Ko'; + } + + return $bytes.' o'; + } + private function addUnsubscribeHeaders(Email $email, string $to): void { $token = $this->unsubscribeManager->generateToken($to); diff --git a/src/Service/MeilisearchService.php b/src/Service/MeilisearchService.php index e214a00..08c2e2f 100644 --- a/src/Service/MeilisearchService.php +++ b/src/Service/MeilisearchService.php @@ -2,8 +2,10 @@ namespace App\Service; +use App\Entity\Advert; use App\Entity\Customer; use App\Entity\CustomerContact; +use App\Entity\Devis; use App\Entity\Domain; use App\Entity\PriceAutomatic; use App\Entity\Revendeur; @@ -228,6 +230,76 @@ class MeilisearchService } } + public function indexDevis(Devis $devis): void + { + try { + $this->client->index('customer_devis')->addDocuments([$this->serializeDevis($devis)]); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: Failed to index devis '.$devis->getId().': '.$e->getMessage()); + } + } + + public function removeDevis(int $devisId): void + { + try { + $this->client->index('customer_devis')->deleteDocument($devisId); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: Failed to remove devis '.$devisId.': '.$e->getMessage()); + } + } + + /** @return list> */ + public function searchDevis(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_devis')->search($query, $options)->getHits(); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: search devis error: '.$e->getMessage()); + + return []; + } + } + + public function indexAdvert(Advert $advert): void + { + try { + $this->client->index('customer_advert')->addDocuments([$this->serializeAdvert($advert)]); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: Failed to index advert '.$advert->getId().': '.$e->getMessage()); + } + } + + public function removeAdvert(int $advertId): void + { + try { + $this->client->index('customer_advert')->deleteDocument($advertId); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: Failed to remove advert '.$advertId.': '.$e->getMessage()); + } + } + + /** @return list> */ + public function searchAdverts(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_advert')->search($query, $options)->getHits(); + } catch (\Throwable $e) { + $this->logger->error('Meilisearch: search adverts error: '.$e->getMessage()); + + return []; + } + } + public function setupIndexes(): void { try { @@ -301,6 +373,30 @@ class MeilisearchService $this->client->index('customer_website')->updateFilterableAttributes([ 'customerId', 'type', 'state', ]); + + try { + $this->client->createIndex('customer_devis', ['primaryKey' => 'id']); + } catch (\Throwable $e) { + $this->logger->warning('Meilisearch: setupIndexes (customer_devis) - '.$e->getMessage()); + } + $this->client->index('customer_devis')->updateSearchableAttributes([ + 'numOrder', 'customerName', 'customerEmail', 'state', + ]); + $this->client->index('customer_devis')->updateFilterableAttributes([ + 'customerId', 'state', + ]); + + try { + $this->client->createIndex('customer_advert', ['primaryKey' => 'id']); + } catch (\Throwable $e) { + $this->logger->warning('Meilisearch: setupIndexes (customer_advert) - '.$e->getMessage()); + } + $this->client->index('customer_advert')->updateSearchableAttributes([ + 'numOrder', 'customerName', 'customerEmail', 'state', + ]); + $this->client->index('customer_advert')->updateFilterableAttributes([ + 'customerId', 'state', + ]); } /** @@ -422,4 +518,40 @@ class MeilisearchService 'customerEmail' => $website->getCustomer()->getEmail(), ]; } + + /** @return array */ + private function serializeDevis(Devis $devis): array + { + $customer = $devis->getCustomer(); + + return [ + 'id' => $devis->getId(), + 'numOrder' => $devis->getOrderNumber()->getNumOrder(), + 'state' => $devis->getState(), + 'totalHt' => $devis->getTotalHt(), + 'totalTtc' => $devis->getTotalTtc(), + 'customerId' => $customer?->getId(), + 'customerName' => $customer?->getFullName(), + 'customerEmail' => $customer?->getEmail(), + 'createdAt' => $devis->getCreatedAt()->format('Y-m-d'), + ]; + } + + /** @return array */ + private function serializeAdvert(Advert $advert): array + { + $customer = $advert->getCustomer(); + + return [ + 'id' => $advert->getId(), + 'numOrder' => $advert->getOrderNumber()->getNumOrder(), + 'state' => $advert->getState(), + 'totalHt' => $advert->getTotalHt(), + 'totalTtc' => $advert->getTotalTtc(), + 'customerId' => $customer?->getId(), + 'customerName' => $customer?->getFullName(), + 'customerEmail' => $customer?->getEmail(), + 'createdAt' => $advert->getCreatedAt()->format('Y-m-d'), + ]; + } } diff --git a/src/Service/OrderNumberService.php b/src/Service/OrderNumberService.php index 44c8b54..cacca20 100644 --- a/src/Service/OrderNumberService.php +++ b/src/Service/OrderNumberService.php @@ -16,10 +16,22 @@ class OrderNumberService /** * Genere le prochain numero de commande au format MM/YYYY-XXXXX. - * Le compteur XXXXX est incremente par mois (reset a 00001 chaque nouveau mois). + * Reutilise en priorite un OrderNumber existant non utilise (isUsed = false), + * sinon cree un nouveau (compteur XXXXX reset chaque mois). */ public function generate(): OrderNumber { + $unused = $this->repository->createQueryBuilder('o') + ->where('o.isUsed = false') + ->orderBy('o.createdAt', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if (null !== $unused) { + return $unused; + } + $now = new \DateTimeImmutable(); $prefix = $now->format('m/Y').'-'; @@ -61,9 +73,22 @@ class OrderNumberService /** * Recupere le prochain numero sans le creer. + * Reutilise un OrderNumber existant non utilise (isUsed = false) en priorite, + * sinon calcule le prochain numero du mois courant. */ public function preview(): string { + $unused = $this->repository->createQueryBuilder('o') + ->where('o.isUsed = false') + ->orderBy('o.createdAt', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + if (null !== $unused) { + return $unused->getNumOrder(); + } + $now = new \DateTimeImmutable(); $prefix = $now->format('m/Y').'-'; diff --git a/src/Service/Pdf/AdvertPdf.php b/src/Service/Pdf/AdvertPdf.php new file mode 100644 index 0000000..0f69160 --- /dev/null +++ b/src/Service/Pdf/AdvertPdf.php @@ -0,0 +1,234 @@ + */ + private array $items = []; + + private string $qrBase64 = ''; + + public function __construct( + private readonly KernelInterface $kernel, + private readonly Advert $advert, + ?UrlGeneratorInterface $urlGenerator = null, + ) { + parent::__construct(); + + // Generation QR code vers la page de paiement + if (null !== $urlGenerator) { + $paymentUrl = $urlGenerator->generate('app_order_payment', [ + 'numOrder' => $advert->getOrderNumber()->getNumOrder(), + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $builder = new Builder( + writer: new PngWriter(), + data: $paymentUrl, + size: 200, + margin: 10, + ); + $this->qrBase64 = 'data:image/png;base64,'.base64_encode($builder->build()->getString()); + } + + $items = []; + foreach ($this->advert->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('Avis de Paiement N° '.$this->advert->getOrderNumber()->getNumOrder())); + } + + public function Header(): void + { + $this->SetFont('Arial', '', 10); + $logo = $this->kernel->getProjectDir().'/public/logo_facture.png'; + if (file_exists($logo)) { + $this->Image($logo, 65, 5, 80); + } + + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + + $numText = $this->enc('AVIS DE PAIEMENT N° '.$this->advert->getOrderNumber()->getNumOrder()); + $dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->advert->getCreatedAt())); + + $this->Text(15, 80, $numText); + $this->Text(15, 85, $dateText); + + $this->SetFont('Arial', 'B', 12); + + $y = 60; + $customer = $this->advert->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(); + } + + private function displayQrCode(): void + { + if ('' === $this->qrBase64) { + return; + } + + $this->SetAutoPageBreak(false); + $y = $this->GetPageHeight() - 55; + + // QR code en bas a gauche + $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, 30, 30); + @unlink($tmpQr); + + // Texte a droite du QR + $this->SetXY(50, $y + 5); + $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->SetTextColor(100, 100, 100); + $this->Cell(100, 5, $this->enc('Flashez ce QR code pour acceder 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; + + $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->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'); + } + + public function Footer(): void + { + $this->SetY(-32); + $this->Ln(10); + $this->SetFont('Arial', 'B', 8); + $this->SetTextColor(253, 140, 4); + $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->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->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'); + } +} diff --git a/src/Service/Pdf/DevisPdf.php b/src/Service/Pdf/DevisPdf.php new file mode 100644 index 0000000..86c191d --- /dev/null +++ b/src/Service/Pdf/DevisPdf.php @@ -0,0 +1,248 @@ + */ + private array $items = []; + + private int $lastDevisPage = 0; + + public function __construct( + private readonly KernelInterface $kernel, + private readonly Devis $devis, + ) { + parent::__construct(); + + $items = []; + foreach ($this->devis->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('Devis N° '.$this->devis->getOrderNumber()->getNumOrder())); + } + + public function Header(): void + { + // Sur les pages CGV importees (au-dela de la derniere page devis), pas de header + if ($this->lastDevisPage > 0 && $this->PageNo() > $this->lastDevisPage) { + return; + } + + $this->SetFont('Arial', '', 10); + $logo = $this->kernel->getProjectDir().'/public/logo_facture.png'; + if (file_exists($logo)) { + $this->Image($logo, 65, 5, 80); + } + + $formatter = new \IntlDateFormatter( + 'fr_FR', + \IntlDateFormatter::FULL, + \IntlDateFormatter::NONE, + 'Europe/Paris', + \IntlDateFormatter::GREGORIAN + ); + + $numDevisText = $this->enc('DEVIS N° '.$this->devis->getOrderNumber()->getNumOrder()); + $dateText = $this->enc('Saint-Quentin, '.$formatter->format($this->devis->getCreatedAt())); + + $this->Text(15, 80, $numDevisText); + $this->Text(15, 85, $dateText); + + $this->SetFont('Arial', 'B', 12); + + $y = 60; + $customer = $this->devis->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(); + + // Titre + $this->SetX(20); + $this->SetFont('Arial', 'B', 11); + $this->Cell(95, 10, $this->enc($item['title']), 0, 0); + + // Prix HT + $this->SetFont('Arial', 'B', 11); + $this->SetXY(142, $currentY); + $this->Cell(39, 8, number_format($item['priceHt'], 2, ',', ' ').' '.EURO, 0, 1, 'R'); + + // Description + $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->appendCgv(); + } + + /** + * Importe les pages de public/cgv.pdf a la suite du devis, sans appliquer le Header/Footer devis. + */ + private function appendCgv(): void + { + $cgvPath = $this->kernel->getProjectDir().'/public/cgv.pdf'; + if (!file_exists($cgvPath)) { + return; + } + + try { + $pageCount = $this->setSourceFile($cgvPath); + + // Marque la derniere page devis : toutes les pages ajoutees apres auront le Header/Footer desactive + $this->lastDevisPage = $this->PageNo(); + + 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); + + // Sur la DERNIERE page CGV : ajoute un champ signature DocuSeal independant + // en bas a gauche, sans declencher de saut de page automatique + if ($i === $pageCount) { + $this->SetAutoPageBreak(false); + $this->SetXY(15, $this->GetPageHeight() - 100); + $this->SetFont('Arial', '', 10); + $this->SetTextColor(0, 0, 0); + $this->Cell(60, 20, '{{SignCGV;type=signature;role=First Party}}', 0, 0, 'L'); + } + } + } 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; + + $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->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'); + } + + public function Footer(): void + { + // Sur les pages CGV importees (au-dela de la derniere page devis), pas de footer + if ($this->lastDevisPage > 0 && $this->PageNo() > $this->lastDevisPage) { + return; + } + + $this->SetY(-32); + $this->Ln(10); + $this->SetFont('Arial', 'B', 8); + $this->SetTextColor(253, 140, 4); + $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->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'); + + // Numero de page avec alias {nb} + $this->SetFont('Arial', 'I', 7); + $this->SetTextColor(150, 150, 150); + $this->Cell(190, 4, $this->enc('Page ').$this->PageNo().' / {nb}', 0, 0, 'C'); + } + + /** + * Encode une chaine UTF-8 en Windows-1252 (requis par FPDF). + */ + private function enc(string $text): string + { + return mb_convert_encoding($text, 'Windows-1252', 'UTF-8'); + } +} diff --git a/symfony.lock b/symfony.lock index e0e89f9..d78bd13 100644 --- a/symfony.lock +++ b/symfony.lock @@ -393,6 +393,15 @@ "templates/base.html.twig" ] }, + "symfony/uid": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, "symfony/validator": { "version": "8.0", "recipe": { diff --git a/templates/admin/clients/create.html.twig b/templates/admin/clients/create.html.twig index bdbe511..0ede52a 100644 --- a/templates/admin/clients/create.html.twig +++ b/templates/admin/clients/create.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Nouveau client - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Nouveau client - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/clients/index.html.twig b/templates/admin/clients/index.html.twig index 2b740e4..ce56c5b 100644 --- a/templates/admin/clients/index.html.twig +++ b/templates/admin/clients/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Clients - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Clients - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/clients/show.html.twig b/templates/admin/clients/show.html.twig index b422745..613ae0c 100644 --- a/templates/admin/clients/show.html.twig +++ b/templates/admin/clients/show.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}{{ customer.fullName }} - Client - CRM SITECONSEIL{% endblock %} +{% block title %}{{ customer.fullName }} - Client - SARL SITECONSEIL{% endblock %} {% block admin_content %}
@@ -463,6 +463,219 @@
Aucun site internet.
{% endif %} + {# Tab: Avis de Paiement #} + {% elseif tab == 'avis' %} +
+

Avis de paiement

+
+
+ + +
+ + {% if advertsList|length > 0 %} +
+ + + + + + + + + + + + + + + {% for a in advertsList %} + + + + + + + + + + + {% endfor %} + +
NumeroDateDevis lieLignesTotal HTTotal TTCEtatActions
{{ a.orderNumber.numOrder }}{{ a.createdAt|date('d/m/Y') }} + {% if a.devis %} + {{ a.devis.orderNumber.numOrder }} + {% else %} + + {% endif %} + {{ a.lines|length }}{{ a.totalHt }} €{{ a.totalTtc }} € + {% if a.state == 'accepted' %} + Accepte + {% elseif a.state == 'refused' %} + Refuse + {% elseif a.state == 'send' %} + Envoye + {% elseif a.state == 'cancel' %} + Annule + {% else %} + Cree + {% endif %} + + {% if a.state != 'cancel' %} +
+ {% if a.advertFile %} + + Voir PDF + +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% if a.advertFile and a.state == 'created' %} +
+ +
+ {% endif %} + {% if a.state == 'send' %} +
+ +
+ {% endif %} +
+ +
+
+ {% else %} + + {% endif %} +
+
+

{{ advertsList|length }} avis de paiement

+ {% else %} +
Aucun avis de paiement.
+ {% endif %} + + {# Tab: Devis #} + {% elseif tab == 'devis' %} +
+

Devis du client

+ + + Creer un devis + +
+
+ + +
+ + {% if devisList|length > 0 %} +
+ + + + + + + + + + + + + + {% for d in devisList %} + + + + + + + + + + {% endfor %} + +
NumeroDateLignesTotal HTTotal TTCEtatActions
{{ d.orderNumber.numOrder }}{{ d.createdAt|date('d/m/Y') }}{{ d.lines|length }}{{ d.totalHt }} €{{ d.totalTtc }} € + {% if d.state == 'accepted' %} + Accepte + {% elseif d.state == 'refused' %} + Refuse + {% elseif d.state == 'send' %} + Envoye + {% elseif d.state == 'cancel' %} + Annule + {% else %} + Cree + {% endif %} + + {% if d.state != 'cancel' %} +
+ {% if d.unsignedPdf %} + + Voir PDF + + {% endif %} + {% if d.unsignedPdf %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% if d.unsignedPdf and d.state == 'created' %} +
+ +
+ {% endif %} + {% if d.state == 'send' %} +
+ +
+ {% endif %} + {% if d.state == 'accepted' and d.advert is null %} +
+ +
+ {% endif %} + {% if d.advert %} + + Avis {{ d.advert.orderNumber.numOrder }} + + {% endif %} + {% if d.submissionId %} + + Evenements + + {% endif %} + + Modifier + +
+ +
+
+ {% else %} + + {% endif %} +
+
+

{{ devisList|length }} devis

+ {% else %} +
Aucun devis.
+ {% endif %} + {# Tab: Securite #} {% elseif tab == 'securite' %} {% set user = customer.user %} diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig index 4d9940e..a8c0509 100644 --- a/templates/admin/dashboard.html.twig +++ b/templates/admin/dashboard.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/devis/create.html.twig b/templates/admin/devis/create.html.twig new file mode 100644 index 0000000..db6fb4f --- /dev/null +++ b/templates/admin/devis/create.html.twig @@ -0,0 +1,104 @@ +{% extends 'admin/_layout.html.twig' %} + +{% set isEdit = isEdit ?? false %} + +{% block title %}{{ isEdit ? 'Modifier' : 'Creer' }} un devis - SARL SITECONSEIL{% endblock %} + +{% block admin_content %} +
+
+

{{ isEdit ? 'Modifier' : 'Creer' }} un devis

+ Retour +
+ +
+
+

Informations

+
+
+ + +

Genere automatiquement

+
+
+ + +
+
+
+ +
+
+

Lignes du devis

+ +
+ +
+

Prestations rapides

+
+ {% for type, p in quickPrices %} + + {% endfor %} +
+
+ +
+ +
+
+
Total HT
+
0.00 EUR
+
+
+
+ +
+ Annuler + +
+
+
+ + + +{% endblock %} diff --git a/templates/admin/devis/events.html.twig b/templates/admin/devis/events.html.twig new file mode 100644 index 0000000..5732373 --- /dev/null +++ b/templates/admin/devis/events.html.twig @@ -0,0 +1,65 @@ +{% extends 'admin/_layout.html.twig' %} + +{% block title %}Evenements devis {{ devis.orderNumber.numOrder }} - SARL SITECONSEIL{% endblock %} + +{% block admin_content %} +
+
+
+

Evenements DocuSeal

+

Devis {{ devis.orderNumber.numOrder }}

+
+ Retour +
+ + {% if events|length > 0 %} +
+ + + + + + + + + + + + {% for e in events %} + + + + + + + + {% endfor %} + +
DateEvenementSubmissionSubmitterPayload
{{ e.createdAt|date('d/m/Y H:i:s') }} + {% set eventType = e.eventType %} + {% if eventType == 'form.viewed' %} + Vu + {% elseif eventType == 'form.started' %} + Demarre + {% elseif eventType == 'form.completed' %} + Signe + {% elseif eventType == 'form.declined' %} + Refuse + {% else %} + {{ eventType }} + {% endif %} + {{ e.submissionId ?? '—' }}{{ e.submitterId ?? '—' }} + {% if e.payload %} +
+ Voir payload +
{{ e.payload }}
+
+ {% endif %} +
+
+

{{ events|length }} evenement(s)

+ {% else %} +
Aucun evenement pour ce devis.
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/facturation/index.html.twig b/templates/admin/facturation/index.html.twig index 96c6ead..264f3f0 100644 --- a/templates/admin/facturation/index.html.twig +++ b/templates/admin/facturation/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Facturation - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Facturation - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/logs/index.html.twig b/templates/admin/logs/index.html.twig index 7c9e257..6924ac5 100644 --- a/templates/admin/logs/index.html.twig +++ b/templates/admin/logs/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Logs - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Logs - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/logs/pdf.html.twig b/templates/admin/logs/pdf.html.twig index 34b4aae..4ac8927 100644 --- a/templates/admin/logs/pdf.html.twig +++ b/templates/admin/logs/pdf.html.twig @@ -1,6 +1,6 @@ {% extends 'pdf/_base.html.twig' %} -{% block title %}Log #{{ log.id }} - CRM SITECONSEIL{% endblock %} +{% block title %}Log #{{ log.id }} - SARL SITECONSEIL{% endblock %} {% block data_table_mt %}8px{% endblock %} {% block data_td_pad %}5px{% endblock %} diff --git a/templates/admin/logs/verify.html.twig b/templates/admin/logs/verify.html.twig index bba8803..9024a87 100644 --- a/templates/admin/logs/verify.html.twig +++ b/templates/admin/logs/verify.html.twig @@ -1,6 +1,6 @@ {% extends 'legal/_layout.html.twig' %} -{% block title %}Verification log #{{ id }} - CRM SITECONSEIL{% endblock %} +{% block title %}Verification log #{{ id }} - SARL SITECONSEIL{% endblock %} {% block description %}Verification de l'integrite du log #{{ id }}.{% endblock %} {% block body %} diff --git a/templates/admin/membres.html.twig b/templates/admin/membres.html.twig index 7ecd283..e477a84 100644 --- a/templates/admin/membres.html.twig +++ b/templates/admin/membres.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Administration - Membres - CRM SITECONSEIL{% endblock %} +{% block title %}Administration - Membres - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/order_number/index.html.twig b/templates/admin/order_number/index.html.twig index 52ae701..70e7654 100644 --- a/templates/admin/order_number/index.html.twig +++ b/templates/admin/order_number/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Numerotation - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Numerotation - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/profil/index.html.twig b/templates/admin/profil/index.html.twig index 6332b03..868aa1b 100644 --- a/templates/admin/profil/index.html.twig +++ b/templates/admin/profil/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Mon profil - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Mon profil - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/revendeurs/create.html.twig b/templates/admin/revendeurs/create.html.twig index 9da3ddf..a15ad74 100644 --- a/templates/admin/revendeurs/create.html.twig +++ b/templates/admin/revendeurs/create.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Nouveau revendeur - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Nouveau revendeur - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/revendeurs/edit.html.twig b/templates/admin/revendeurs/edit.html.twig index 01f77e9..11b0613 100644 --- a/templates/admin/revendeurs/edit.html.twig +++ b/templates/admin/revendeurs/edit.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Modifier {{ revendeur.codeRevendeur }} - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Modifier {{ revendeur.codeRevendeur }} - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/revendeurs/index.html.twig b/templates/admin/revendeurs/index.html.twig index 8a496e4..dcb3000 100644 --- a/templates/admin/revendeurs/index.html.twig +++ b/templates/admin/revendeurs/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Revendeurs - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Revendeurs - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/services/esyweb.html.twig b/templates/admin/services/esyweb.html.twig index 7b97ca5..462a4d7 100644 --- a/templates/admin/services/esyweb.html.twig +++ b/templates/admin/services/esyweb.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Esy-Web - Sites Internet - CRM SITECONSEIL{% endblock %} +{% block title %}Esy-Web - Sites Internet - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/services/index.html.twig b/templates/admin/services/index.html.twig index 82dbbf7..19fc4f3 100644 --- a/templates/admin/services/index.html.twig +++ b/templates/admin/services/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Services - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Services - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/services/ndd.html.twig b/templates/admin/services/ndd.html.twig index 041a8e3..1cae62c 100644 --- a/templates/admin/services/ndd.html.twig +++ b/templates/admin/services/ndd.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Noms de domaine - Services - CRM SITECONSEIL{% endblock %} +{% block title %}Noms de domaine - Services - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/stats/index.html.twig b/templates/admin/stats/index.html.twig index 13a787f..45f8054 100644 --- a/templates/admin/stats/index.html.twig +++ b/templates/admin/stats/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Statistiques - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Statistiques - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/status/index.html.twig b/templates/admin/status/index.html.twig index 0eb6f28..38bfbe7 100644 --- a/templates/admin/status/index.html.twig +++ b/templates/admin/status/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Status des services - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Status des services - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/status/manage.html.twig b/templates/admin/status/manage.html.twig index 9d4fad6..bdfbbad 100644 --- a/templates/admin/status/manage.html.twig +++ b/templates/admin/status/manage.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Gerer les services - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Gerer les services - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/admin/sync/index.html.twig b/templates/admin/sync/index.html.twig index 4e5c7ac..e406f67 100644 --- a/templates/admin/sync/index.html.twig +++ b/templates/admin/sync/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Synchronisation - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Synchronisation - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
@@ -115,6 +115,48 @@
+ {# Sync Devis #} +
+
+
+
+ +
+
+

Devis

+

Index Meilisearch : customer_devis

+

{{ totalDevis }} devis en base

+
+
+
+ +
+
+
+ + {# Sync Avis de paiement #} +
+
+
+
+ +
+
+

Avis de paiement

+

Index Meilisearch : customer_advert

+

{{ totalAdverts }} avis en base

+
+
+
+ +
+
+
+ {# Sync revendeurs #}
diff --git a/templates/admin/tarification/index.html.twig b/templates/admin/tarification/index.html.twig index a77172e..069ebd0 100644 --- a/templates/admin/tarification/index.html.twig +++ b/templates/admin/tarification/index.html.twig @@ -1,6 +1,6 @@ {% extends 'admin/_layout.html.twig' %} -{% block title %}Tarification - Administration - CRM SITECONSEIL{% endblock %} +{% block title %}Tarification - Administration - SARL SITECONSEIL{% endblock %} {% block admin_content %}
diff --git a/templates/attestation/not_found.html.twig b/templates/attestation/not_found.html.twig index e539d3c..5710e8a 100644 --- a/templates/attestation/not_found.html.twig +++ b/templates/attestation/not_found.html.twig @@ -1,6 +1,6 @@ {% extends 'legal/_layout.html.twig' %} -{% block title %}Attestation introuvable - CRM SITECONSEIL{% endblock %} +{% block title %}Attestation introuvable - SARL SITECONSEIL{% endblock %} {% block body %}
diff --git a/templates/attestation/verify.html.twig b/templates/attestation/verify.html.twig index 6b4d8a1..5bb39c6 100644 --- a/templates/attestation/verify.html.twig +++ b/templates/attestation/verify.html.twig @@ -1,6 +1,6 @@ {% extends 'legal/_layout.html.twig' %} -{% block title %}Verification attestation {{ attestation.reference }} - CRM SITECONSEIL{% endblock %} +{% block title %}Verification attestation {{ attestation.reference }} - SARL SITECONSEIL{% endblock %} {% block description %}Verification de l'attestation RGPD {{ attestation.reference }}.{% endblock %} {% block body %} diff --git a/templates/base.html.twig b/templates/base.html.twig index d6b6e3b..4764639 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -3,7 +3,7 @@ - {% block title %}CRM SITECONSEIL{% endblock %} + {% block title %}SARL SITECONSEIL{% endblock %} {{ pwa() }} {% block meta %} diff --git a/templates/devis/process.html.twig b/templates/devis/process.html.twig new file mode 100644 index 0000000..9ab228f --- /dev/null +++ b/templates/devis/process.html.twig @@ -0,0 +1,121 @@ +{% extends 'base.html.twig' %} + +{% block title %}Devis {{ devis.orderNumber.numOrder }} - SARL SITECONSEIL{% endblock %} + +{% block body %} +
+ {% for type, messages in app.flashes %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endfor %} + +
+
+
+

Devis

+

{{ devis.orderNumber.numOrder }}

+

Date : {{ devis.createdAt|date('d/m/Y') }}

+
+
+ {% if devis.state == 'accepted' %} + Signe + {% elseif devis.state == 'refused' %} + Refuse + {% elseif devis.state == 'cancel' %} + Annule + {% else %} + En attente de signature + {% endif %} +
+
+ +
+
+

Emetteur

+

SARL SITECONSEIL

+

+ 27 rue Le Sérurier
+ 02100 Saint-Quentin, France
+ SIREN 943121517 +

+
+
+

Client

+

{{ customer.fullName }}

+

+ {% if customer.address %}{{ customer.address }}
{% endif %} + {% if customer.zipCode or customer.city %}{{ customer.zipCode }} {{ customer.city }}
{% endif %} + {% if customer.email %}{{ customer.email }}{% endif %} +

+
+
+ +

Detail des prestations

+
+ + + + + + + + + + {% for line in devis.lines %} + + + + + + {% endfor %} + +
#PrestationPrix HT
{{ loop.index }} +
{{ line.title }}
+ {% if line.description %} +
{{ line.description }}
+ {% endif %} +
{{ line.priceHt }} €
+
+ +
+
+
+ Total HT + {{ devis.totalHt }} € +
+
+ TVA 20% + {{ devis.totalTva }} € +
+
+ Total TTC + {{ devis.totalTtc }} € +
+
+
+
+ + {% if devis.state == 'send' %} +
+

Votre decision

+
+ + Signer le devis + + +
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/devis/refused.html.twig b/templates/devis/refused.html.twig new file mode 100644 index 0000000..b84f12d --- /dev/null +++ b/templates/devis/refused.html.twig @@ -0,0 +1,33 @@ +{% extends 'base.html.twig' %} + +{% block title %}Devis refuse - SARL SITECONSEIL{% endblock %} + +{% block body %} +
+
+
+ + + +
+ +

Devis refuse

+

+ Bonjour {{ customer.firstName }},
+ Votre refus du devis {{ devis.orderNumber.numOrder }} a bien ete enregistre.
+ Notre equipe a ete notifiee et reviendra vers vous si besoin. +

+ + {% if devis.raisonMessage %} +
+

Motif transmis

+

{{ devis.raisonMessage }}

+
+ {% endif %} + +

+ Une question ? contact@siteconseil.fr +

+
+
+{% endblock %} diff --git a/templates/devis/signed.html.twig b/templates/devis/signed.html.twig new file mode 100644 index 0000000..9b66747 --- /dev/null +++ b/templates/devis/signed.html.twig @@ -0,0 +1,46 @@ +{% extends 'base.html.twig' %} + +{% block title %}Devis signe - SARL SITECONSEIL{% endblock %} + +{% block body %} +
+
+
+ + + +
+ +

Merci {{ customer.firstName }} !

+

+ Votre devis {{ devis.orderNumber.numOrder }} a bien ete signe.
+ Notre equipe a ete notifiee et vous recevrez prochainement votre facture par email. +

+ +
+
+
+

Numero

+

{{ devis.orderNumber.numOrder }}

+
+
+

Date signature

+

{{ "now"|date('d/m/Y') }}

+
+
+

Total HT

+

{{ devis.totalHt }} €

+
+
+

Total TTC

+

{{ devis.totalTtc }} €

+
+
+
+ +

+ Une question ? contact@siteconseil.fr +

+
+
+{% endblock %} diff --git a/templates/dns_report/index.html.twig b/templates/dns_report/index.html.twig index b1c0284..9244603 100644 --- a/templates/dns_report/index.html.twig +++ b/templates/dns_report/index.html.twig @@ -1,6 +1,6 @@ {% extends 'base.html.twig' %} -{% block title %}Rapport DNS - CRM SITECONSEIL{% endblock %} +{% block title %}Rapport DNS - SARL SITECONSEIL{% endblock %} {% block header %}
diff --git a/templates/email/base.html.twig b/templates/email/base.html.twig index 5867f6e..6349d80 100644 --- a/templates/email/base.html.twig +++ b/templates/email/base.html.twig @@ -3,7 +3,7 @@ - {% block title %}CRM SITECONSEIL{% endblock %} + {% block title %}SARL SITECONSEIL{% endblock %}