update
This commit is contained in:
@@ -12,9 +12,9 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| Version | 0.3.0 |
|
||||
| Status | v0.3 Complete |
|
||||
| Last Updated | 2026-03-15 |
|
||||
| Version | 0.4.0 |
|
||||
| Status | v0.4 Complete |
|
||||
| Last Updated | 2026-03-17 |
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -32,13 +32,14 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
|
||||
- [x] Wystawianie paragonów z zamówienia (formularz, snapshoty, atomowe numerowanie) — Phase 10
|
||||
- [x] Podgląd i wydruk paragonu (HTML+PDF, dompdf) — Phase 11
|
||||
- [x] Sekcja Księgowość — lista paragonów z filtrami, paginacją, eksportem XLSX — Phase 12
|
||||
|
||||
### Active (In Progress)
|
||||
|
||||
- [x] DB Foundation: tabele email_mailboxes, email_templates, email_logs — Phase 13
|
||||
- [x] Skrzynki pocztowe SMTP (CRUD + test połączenia) — Phase 13
|
||||
- [x] Szablony wiadomości e-mail (CRUD + Quill.js + system zmiennych + załączniki) — Phase 14
|
||||
- [ ] Wysyłka e-mail z zamówień (resolwer zmiennych, załączniki, log) — Phase 15
|
||||
- [x] Wysyłka e-mail z zamówień (resolwer zmiennych, załączniki, log) — Phase 15
|
||||
|
||||
### Active (In Progress)
|
||||
|
||||
- (brak — v0.5 do zaplanowania)
|
||||
|
||||
### Planned (Next)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
|
||||
|
||||
## Current Milestone
|
||||
|
||||
### v0.4 Moduł E-mail — In progress
|
||||
### v0.4 Moduł E-mail — Complete ✓ (2026-03-17)
|
||||
|
||||
Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wysyłka maili z zamówień z załącznikami.
|
||||
|
||||
@@ -14,7 +14,7 @@ Skrzynki pocztowe SMTP, szablony wiadomości z systemem zmiennych (Quill.js), wy
|
||||
|-------|------|-------|--------|
|
||||
| 13 | DB + Skrzynki pocztowe | 1/1 | Complete ✓ |
|
||||
| 14 | Szablony wiadomości | 2/2 | Complete ✓ |
|
||||
| 15 | Wysyłka e-mail z zamówień | TBD | Not started |
|
||||
| 15 | Wysyłka e-mail z zamówień | 1/1 | Complete ✓ |
|
||||
|
||||
## Completed Milestones
|
||||
|
||||
|
||||
@@ -5,31 +5,31 @@
|
||||
See: .paul/PROJECT.md (updated 2026-03-12)
|
||||
|
||||
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
|
||||
**Current focus:** v0.4 Moduł E-mail — Skrzynki pocztowe, szablony wiadomości, wysyłka z zamówień.
|
||||
**Current focus:** v0.4 Moduł E-mail — COMPLETE ✓. Następny milestone do zaplanowania.
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v0.4 Moduł E-mail
|
||||
Phase: [3] of [3] (Wysyłka e-mail z zamówień) — Not started
|
||||
Plan: Not started
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-03-16 — Phase 14 complete, transitioned to Phase 15
|
||||
Milestone: v0.4 Moduł E-mail — COMPLETE ✓
|
||||
Phase: [3] of [3] (Wysyłka e-mail z zamówień) — Complete ✓
|
||||
Plan: 15-01 complete
|
||||
Status: Phase 15 complete, milestone v0.4 complete
|
||||
Last activity: 2026-03-17 — UNIFY complete, Phase 15 + milestone v0.4 closed
|
||||
|
||||
Progress:
|
||||
- v0.1 Initial Release: [██████████] 100% ✓
|
||||
- v0.2 Pre-Expansion Fixes: [██████████] 100% ✓
|
||||
- v0.3 Moduł Paragonów: [██████████] 100% ✓
|
||||
- v0.4 Moduł E-mail: [███████░░░] 67%
|
||||
- v0.4 Moduł E-mail: [██████████] 100% ✓
|
||||
- Phase 13: [██████████] 100% ✓
|
||||
- Phase 14: [██████████] 100% ✓
|
||||
- Phase 15: [░░░░░░░░░░] 0% ← next
|
||||
- Phase 15: [██████████] 100% ✓
|
||||
|
||||
## Loop Position
|
||||
|
||||
Current loop state:
|
||||
```
|
||||
PLAN ──▶ APPLY ──▶ UNIFY
|
||||
○ ○ ○ [Ready for next PLAN — Phase 15]
|
||||
✓ ✓ ✓ [Loop complete — milestone v0.4 done]
|
||||
```
|
||||
|
||||
## Accumulated Context
|
||||
@@ -59,6 +59,14 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
| 2026-03-15 | POST eksport z CSRF + dwa tryby (zaznaczone/wszystkie z filtra) | Faza 12 | Bezpieczny eksport; selectable table-list reuse |
|
||||
| 2026-03-16 | ATTACHMENT_TYPES jako centralna mapa typów załączników | Faza 14 | Rozszerzalność: nowy typ = 1 linia w tablicy PHP |
|
||||
| 2026-03-16 | Quill.js 2.0.3 CDN dla edytora szablonów | Faza 14 | Brak build pipeline; CDN prostszy |
|
||||
| 2026-03-17 | PHPMailer v7.0.2 jako SMTP transport | Faza 15 | Nowa zależność composer; in-memory attachments (addStringAttachment) |
|
||||
| 2026-03-17 | Email history jako wpisy w order_activity_log (nie osobna sekcja) | Faza 15 | Spójność z istniejącym UX — jeden timeline zamiast fragmentacji |
|
||||
| 2026-03-17 | VariableResolver wydzielony z EmailTemplateController | Faza 15 | Reuse logiki zmiennych; resolwer niezależny od kontrolera szablonów |
|
||||
|
||||
### Skill Audit (Faza 15, Plan 01)
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
|------------|---------|-------|
|
||||
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym milestone |
|
||||
|
||||
### Skill Audit (Faza 14, Plan 02)
|
||||
| Oczekiwany | Wywołany | Uwagi |
|
||||
@@ -154,7 +162,7 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
||||
- **Delivery mapping "Szukaj..." layout** — JS `attachSelectFilter()` w allegro.php tworzy input search dla InPost/Apaczka selectów, wizualnie wygląda jakby należał do wiersza powyżej. Pre-existing bug, do naprawy osobno.
|
||||
|
||||
### Git State
|
||||
Last commit: 2f73a94 (feat(14-email-templates): CRUD szablonów e-mail z Quill.js + załączniki)
|
||||
Last commit: pending — Phase 15 + milestone v0.4 complete, awaiting commit
|
||||
Branch: main
|
||||
Feature branches merged: none
|
||||
|
||||
@@ -163,16 +171,16 @@ Brak.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-16
|
||||
Stopped at: Phase 14 complete, ready to plan Phase 15
|
||||
Next action: /paul:plan for Phase 15 (Wysyłka e-mail z zamówień)
|
||||
Resume file: .paul/ROADMAP.md
|
||||
Last session: 2026-03-17
|
||||
Stopped at: Milestone v0.4 complete
|
||||
Next action: /paul:complete-milestone or /paul:discuss-milestone for v0.5
|
||||
Resume file: .paul/phases/15-email-sending/15-01-SUMMARY.md
|
||||
Resume context:
|
||||
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
|
||||
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
|
||||
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
|
||||
- v0.4: IN PROGRESS — Phase 13+14 complete, Phase 15 next
|
||||
- Phase 14: CRUD szablonów + Quill.js + zmienne + załączniki (ATTACHMENT_TYPES)
|
||||
- v0.4: COMPLETE ✓ (3 phases, 4 plans) — Moduł E-mail
|
||||
- Gotowe: skrzynki SMTP, szablony Quill.js, wysyłka z zamówień + załączniki + activity_log
|
||||
|
||||
---
|
||||
*STATE.md — Updated after every significant action*
|
||||
|
||||
336
.paul/phases/15-email-sending/15-01-PLAN.md
Normal file
336
.paul/phases/15-email-sending/15-01-PLAN.md
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
phase: 15-email-sending
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- composer.json
|
||||
- composer.lock
|
||||
- src/Modules/Email/EmailSendingService.php
|
||||
- src/Modules/Email/VariableResolver.php
|
||||
- src/Modules/Email/AttachmentGenerator.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/scss/modules/_email-send.scss
|
||||
- resources/views/orders/partials/email-send-modal.php
|
||||
- routes/web.php
|
||||
- DOCS/DB_SCHEMA.md
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
autonomous: false
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Zaimplementować pełny flow wysyłki e-mail z widoku zamówienia: wybór szablonu, podgląd z rozwiązanymi zmiennymi, wysyłka przez SMTP (PHPMailer), automatyczne załączniki (paragon PDF), logowanie do email_logs.
|
||||
|
||||
## Purpose
|
||||
Zamyka milestone v0.4 Moduł E-mail — sprzedawca może wysyłać maile do kupujących bezpośrednio z zamówienia, z gotowymi szablonami i załącznikami, bez opuszczania panelu.
|
||||
|
||||
## Output
|
||||
- PHPMailer jako zależność composer
|
||||
- EmailSendingService: resolwer zmiennych, generowanie załączników, wysyłka SMTP
|
||||
- Modal wysyłki e-mail na widoku zamówienia (wybór szablonu, podgląd, wysyłka)
|
||||
- Endpoint POST /orders/{id}/send-email
|
||||
- Logowanie wysyłek w email_logs z wyświetleniem w zakładce Dokumenty
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
|
||||
## Prior Work
|
||||
@.paul/phases/13-email-mailboxes/13-01-SUMMARY.md — DB foundation, SMTP mailbox CRUD, IntegrationSecretCipher
|
||||
@.paul/phases/14-email-templates/14-01-SUMMARY.md — Template CRUD, Quill.js, VARIABLE_GROUPS, ATTACHMENT_TYPES
|
||||
|
||||
## Source Files
|
||||
@src/Modules/Settings/EmailMailboxRepository.php
|
||||
@src/Modules/Settings/EmailTemplateRepository.php
|
||||
@src/Modules/Settings/EmailTemplateController.php — VARIABLE_GROUPS, SAMPLE_DATA, ATTACHMENT_TYPES
|
||||
@src/Modules/Settings/IntegrationSecretCipher.php
|
||||
@src/Modules/Orders/OrdersController.php
|
||||
@src/Modules/Accounting/ReceiptController.php — pdf() method, dompdf pattern
|
||||
@src/Modules/Accounting/ReceiptRepository.php
|
||||
@resources/views/orders/show.php
|
||||
@routes/web.php
|
||||
@database/migrations/20260315_000056_create_email_logs_table.sql
|
||||
</context>
|
||||
|
||||
<skills>
|
||||
## Required Skills (from SPECIAL-FLOWS.md)
|
||||
|
||||
| Skill | Priority | When to Invoke | Loaded? |
|
||||
|-------|----------|----------------|---------|
|
||||
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
|
||||
|
||||
## Skill Invocation Checklist
|
||||
- [ ] sonar-scanner loaded (run after APPLY)
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: Wysyłka e-mail z zamówienia
|
||||
```gherkin
|
||||
Given użytkownik jest na widoku zamówienia z adresem e-mail kupującego
|
||||
When kliknie "Wyślij e-mail", wybierze szablon i kliknie "Wyślij"
|
||||
Then e-mail zostanie wysłany na adres kupującego przez skonfigurowaną skrzynkę SMTP
|
||||
And zmienne w szablonie ({{zamowienie.numer}}, {{kupujacy.imie_nazwisko}} itd.) zostaną zastąpione danymi zamówienia
|
||||
```
|
||||
|
||||
## AC-2: Załącznik paragon PDF
|
||||
```gherkin
|
||||
Given szablon ma ustawiony attachment_1 = 'receipt'
|
||||
And zamówienie ma wystawiony paragon
|
||||
When użytkownik wyśle e-mail z tym szablonem
|
||||
Then do maila zostanie dołączony paragon w formacie PDF
|
||||
And nazwa pliku to numer_paragonu.pdf
|
||||
```
|
||||
|
||||
## AC-3: Logowanie wysyłek
|
||||
```gherkin
|
||||
Given użytkownik wysłał e-mail z zamówienia
|
||||
When otworzy zakładkę Dokumenty na widoku zamówienia
|
||||
Then zobaczy wpis z datą, tematem, odbiorcą, statusem (sent/failed) i opcją podglądu
|
||||
```
|
||||
|
||||
## AC-4: Podgląd przed wysyłką
|
||||
```gherkin
|
||||
Given użytkownik wybrał szablon w modalu wysyłki
|
||||
When kliknie "Podgląd"
|
||||
Then zobaczy temat i treść z rozwiązanymi zmiennymi (dane z aktualnego zamówienia)
|
||||
And zobaczy informację o załącznikach (jeśli dostępne)
|
||||
```
|
||||
|
||||
## AC-5: Walidacja i obsługa błędów
|
||||
```gherkin
|
||||
Given brak skonfigurowanej skrzynki SMTP lub brak aktywnych szablonów
|
||||
When użytkownik kliknie "Wyślij e-mail"
|
||||
Then zobaczy komunikat o braku konfiguracji (nie modal wysyłki)
|
||||
|
||||
Given wysyłka SMTP nie powiedzie się
|
||||
When błąd zostanie zwrócony przez PHPMailer
|
||||
Then email_logs zapisze status='failed' z error_message
|
||||
And użytkownik zobaczy komunikat o błędzie
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: PHPMailer + EmailSendingService z resolwerem zmiennych i załącznikami</name>
|
||||
<files>
|
||||
composer.json, composer.lock,
|
||||
src/Modules/Email/EmailSendingService.php,
|
||||
src/Modules/Email/VariableResolver.php,
|
||||
src/Modules/Email/AttachmentGenerator.php
|
||||
</files>
|
||||
<action>
|
||||
1. `composer require phpmailer/phpmailer` (v6.x)
|
||||
|
||||
2. Utworzyć `src/Modules/Email/VariableResolver.php`:
|
||||
- Metoda `resolve(string $template, array $orderData): string`
|
||||
- Zamienia `{{grupa.zmienna}}` na wartości z danych zamówienia
|
||||
- Mapowanie zmiennych (reuse logiki z EmailTemplateController::VARIABLE_GROUPS):
|
||||
- zamowienie: numer (external_id lub id), numer_zewnetrzny, zrodlo (source), kwota (total_amount), waluta (currency), data (created_at formatted)
|
||||
- kupujacy: imie_nazwisko, email, telefon, login — z buyer_* pól zamówienia
|
||||
- adres: ulica, miasto, kod_pocztowy, kraj — z delivery address
|
||||
- firma: nazwa, nip — z company_settings
|
||||
- Zmienne bez wartości → pusty string (nie zostawiać {{...}})
|
||||
- Metoda `buildVariableMap(array $order, array $companySettings): array` — buduje płaską mapę 'grupa.zmienna' => wartość
|
||||
|
||||
3. Utworzyć `src/Modules/Email/AttachmentGenerator.php`:
|
||||
- Metoda `generate(string $type, array $order): ?array` → ['filename' => '...', 'content' => '...binary...', 'mime' => 'application/pdf']
|
||||
- Dla type='receipt':
|
||||
- Pobrać paragon z ReceiptRepository::findByOrderId($orderId)
|
||||
- Jeśli brak paragonu → return null (nie blokuje wysyłki, po prostu brak załącznika)
|
||||
- Renderować PDF tym samym wzorcem co ReceiptController::pdf() — Dompdf z widoku receipts/print
|
||||
- Filename: str_replace(['/', '\\'], '_', $receiptNumber) . '.pdf'
|
||||
- Rozszerzalność: switch($type) z case 'receipt', default → null
|
||||
|
||||
4. Utworzyć `src/Modules/Email/EmailSendingService.php`:
|
||||
- Konstruktor: Medoo $db, VariableResolver, AttachmentGenerator, IntegrationSecretCipher, TemplateEngine
|
||||
- Metoda `send(int $orderId, int $templateId, ?int $mailboxId = null): array`
|
||||
- Pobrać zamówienie (z buyer, address, items)
|
||||
- Pobrać szablon z EmailTemplateRepository
|
||||
- Pobrać skrzynkę: jeśli mailboxId podany → użyj; jeśli szablon ma mailbox_id → użyj; fallback → domyślna (is_default=1)
|
||||
- Jeśli brak skrzynki → throw RuntimeException('Brak skonfigurowanej skrzynki SMTP')
|
||||
- Pobrać company_settings dla zmiennych firma.*
|
||||
- Rozwiązać zmienne w subject i body_html przez VariableResolver
|
||||
- Wygenerować załączniki przez AttachmentGenerator (jeśli attachment_1 set)
|
||||
- Wysłać przez PHPMailer:
|
||||
- SMTP auth z odszyfrowanym hasłem (IntegrationSecretCipher::decrypt)
|
||||
- SMTPSecure = mailbox.smtp_encryption (tls/ssl/none→'')
|
||||
- Port = mailbox.smtp_port
|
||||
- From = mailbox.sender_email / sender_name
|
||||
- To = order buyer email
|
||||
- Subject = resolved subject
|
||||
- Body = resolved body_html (isHTML = true)
|
||||
- Attachments: addStringAttachment() dla każdego wygenerowanego
|
||||
- Zalogować do email_logs: template_id, mailbox_id, order_id, recipient_email, recipient_name, subject, body_html (resolved), attachments_json, status (sent/failed), error_message, sent_at
|
||||
- Return: ['success' => bool, 'error' => ?string, 'log_id' => int]
|
||||
- Metoda `preview(int $orderId, int $templateId): array`
|
||||
- Rozwiązuje zmienne i zwraca ['subject' => '...', 'body_html' => '...', 'attachments' => [...nazwy]]
|
||||
- NIE wysyła maila
|
||||
|
||||
Avoid:
|
||||
- Nie używać natywnej funkcji mail() — tylko PHPMailer SMTP
|
||||
- Nie duplikować logiki zmiennych z EmailTemplateController — wydzielić do VariableResolver i docelowo zastąpić SAMPLE_DATA w kontrolerze referencją
|
||||
- Nie tworzyć plików tymczasowych na dysku dla załączników — używać addStringAttachment (in-memory)
|
||||
</action>
|
||||
<verify>
|
||||
- composer show phpmailer/phpmailer zwraca wersję 6.x
|
||||
- php -l src/Modules/Email/EmailSendingService.php — brak błędów składni
|
||||
- php -l src/Modules/Email/VariableResolver.php — brak błędów składni
|
||||
- php -l src/Modules/Email/AttachmentGenerator.php — brak błędów składni
|
||||
</verify>
|
||||
<done>AC-1 (wysyłka SMTP), AC-2 (załącznik paragon), AC-5 (obsługa błędów) — backend ready</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Modal wysyłki e-mail + endpoint + logowanie + wyświetlanie w Dokumentach</name>
|
||||
<files>
|
||||
src/Modules/Orders/OrdersController.php,
|
||||
resources/views/orders/show.php,
|
||||
resources/views/orders/partials/email-send-modal.php,
|
||||
resources/scss/modules/_email-send.scss,
|
||||
routes/web.php,
|
||||
DOCS/DB_SCHEMA.md,
|
||||
DOCS/ARCHITECTURE.md,
|
||||
DOCS/TECH_CHANGELOG.md
|
||||
</files>
|
||||
<action>
|
||||
1. Dodać route w routes/web.php:
|
||||
- POST /orders/{id}/send-email → [$ordersController, 'sendEmail']
|
||||
- POST /orders/{id}/email-preview → [$ordersController, 'emailPreview']
|
||||
|
||||
2. W OrdersController dodać metody:
|
||||
- `sendEmail(Request $request)`:
|
||||
- CSRF validation (_token)
|
||||
- Pobrać template_id, mailbox_id (opcjonalny) z POST
|
||||
- Wywołać EmailSendingService::send()
|
||||
- Flash message sukces/błąd
|
||||
- JSON response (AJAX): ['success' => bool, 'message' => '...']
|
||||
- `emailPreview(Request $request)`:
|
||||
- Pobrać template_id z POST
|
||||
- Wywołać EmailSendingService::preview()
|
||||
- JSON response: ['subject' => '...', 'body_html' => '...', 'attachments' => [...]]
|
||||
|
||||
3. Utworzyć `resources/views/orders/partials/email-send-modal.php`:
|
||||
- Modal overlay z klasą .email-send-modal
|
||||
- Formularz:
|
||||
- Select "Szablon" — lista aktywnych szablonów (przekazane z kontrolera)
|
||||
- Select "Skrzynka" — lista aktywnych skrzynek (z opcją "Domyślna z szablonu")
|
||||
- Readonly "Odbiorca" — buyer email z zamówienia
|
||||
- Przycisk "Podgląd" — AJAX POST /orders/{id}/email-preview → wyświetla resolved subject + body w div.preview
|
||||
- Div .email-preview-area — ukryty domyślnie, pokazuje podgląd subject + body + lista załączników
|
||||
- Przycisk "Wyślij" — AJAX POST /orders/{id}/send-email
|
||||
- Przycisk "Anuluj" — zamyka modal
|
||||
- CSRF token hidden input
|
||||
- JS: fetch API do podglądu i wysyłki, loading states, error handling
|
||||
- Po udanej wysyłce: zamknij modal, pokaż OrderProAlerts.success(), odśwież sekcję dokumentów (lub reload)
|
||||
|
||||
4. W resources/views/orders/show.php:
|
||||
- Dodać przycisk "Wyślij e-mail" w .order-details-actions (po "Wystaw paragon"):
|
||||
- `<button class="btn btn--secondary" id="btn-send-email">Wyślij e-mail</button>`
|
||||
- Przycisk aktywny tylko jeśli: są aktywne szablony + aktywne skrzynki + zamówienie ma buyer email
|
||||
- Jeśli brak konfiguracji → btn--disabled z title="Skonfiguruj skrzynkę i szablony w Ustawieniach"
|
||||
- Include modal partial: `<?php include __DIR__ . '/partials/email-send-modal.php'; ?>`
|
||||
- W zakładce "documents" dodać sekcję "Wysłane e-maile":
|
||||
- Tabela: Data | Temat | Odbiorca | Status | Akcje
|
||||
- Status: badge sent (zielony) / failed (czerwony)
|
||||
- Akcje: przycisk "Podgląd" otwierający modal z body_html z logu
|
||||
- Dane z email_logs WHERE order_id = current order
|
||||
- W kontrolerze show(): załadować email_logs dla zamówienia + aktywne szablony + aktywne skrzynki
|
||||
|
||||
5. Style SCSS w resources/scss/modules/_email-send.scss:
|
||||
- .email-send-modal — overlay + centered card (wzorować na istniejących modalach w projekcie)
|
||||
- .email-preview-area — border, padding, max-height z overflow-y scroll
|
||||
- .email-log-status--sent / --failed badges
|
||||
- Import w głównym SCSS
|
||||
|
||||
6. Zaktualizować DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md:
|
||||
- Nowy moduł Email (EmailSendingService, VariableResolver, AttachmentGenerator)
|
||||
- Nowe endpointy /orders/{id}/send-email i /email-preview
|
||||
- PHPMailer jako nowa zależność
|
||||
|
||||
Avoid:
|
||||
- Nie dodawać natywnych alert()/confirm() — używać OrderProAlerts
|
||||
- Nie trzymać styli w pliku widoku — tylko SCSS
|
||||
- Nie tworzyć nowego taba na widoku zamówienia — wyświetlać emaile w istniejącej zakładce Dokumenty
|
||||
- Nie dodawać bulk email z listy zamówień — tylko z widoku pojedynczego zamówienia
|
||||
</action>
|
||||
<verify>
|
||||
- php -l src/Modules/Orders/OrdersController.php — brak błędów składni
|
||||
- Przycisk "Wyślij e-mail" widoczny na /orders/{id}
|
||||
- Modal otwiera się po kliknięciu
|
||||
- Podgląd pokazuje rozwiązane zmienne
|
||||
- Po wysyłce email_logs ma nowy rekord
|
||||
- Zakładka Dokumenty pokazuje historię wysyłek
|
||||
</verify>
|
||||
<done>AC-1 (pełny flow UI), AC-3 (logowanie i wyświetlanie), AC-4 (podgląd), AC-5 (walidacja UI) satisfied</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>Pełny flow wysyłki e-mail z widoku zamówienia: wybór szablonu, podgląd ze zmiennymi, wysyłka SMTP z załącznikiem paragon PDF, logowanie i historia w zakładce Dokumenty.</what-built>
|
||||
<how-to-verify>
|
||||
1. Otwórz zamówienie z adresem e-mail kupującego: /orders/{id}
|
||||
2. Sprawdź przycisk "Wyślij e-mail" w pasku akcji
|
||||
3. Kliknij — powinien otworzyć się modal z wyborem szablonu i skrzynki
|
||||
4. Wybierz szablon → kliknij "Podgląd" → sprawdź czy zmienne zostały rozwiązane (numer zamówienia, dane kupującego)
|
||||
5. Jeśli szablon ma załącznik "Paragon" i zamówienie ma paragon → podgląd powinien pokazać załącznik
|
||||
6. Kliknij "Wyślij" → sprawdź czy mail dotarł na skrzynkę odbiorcy
|
||||
7. Sprawdź zakładkę Dokumenty → sekcja "Wysłane e-maile" powinna pokazywać nowy wpis ze statusem "sent"
|
||||
8. Przetestuj błąd: zmień SMTP na nieprawidłowy → wyślij → status powinien być "failed" z error_message
|
||||
9. Przetestuj brak konfiguracji: usuń skrzynki → przycisk powinien być nieaktywny z tooltipem
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- database/migrations/* — tabele email_logs, email_mailboxes, email_templates już istnieją (Phase 13-14)
|
||||
- src/Modules/Settings/EmailMailboxController.php — CRUD skrzynek bez zmian
|
||||
- src/Modules/Settings/EmailTemplateController.php — CRUD szablonów bez zmian (poza ewentualnym reuse VARIABLE_GROUPS)
|
||||
- src/Modules/Accounting/* — moduł paragonów bez zmian (tylko odczyt z ReceiptRepository)
|
||||
|
||||
## SCOPE LIMITS
|
||||
- Brak bulk email z listy zamówień — tylko z widoku pojedynczego zamówienia
|
||||
- Brak kolejki/retry — wysyłka synchroniczna (async/cron to przyszły milestone)
|
||||
- Brak edycji treści maila w modalu — tylko wybór szablonu i podgląd
|
||||
- Brak nowych tabel/migracji — wykorzystanie istniejącej email_logs
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] composer show phpmailer/phpmailer zwraca 6.x
|
||||
- [ ] php -l na wszystkich nowych/zmodyfikowanych plikach PHP — brak błędów
|
||||
- [ ] Przycisk "Wyślij e-mail" widoczny na widoku zamówienia
|
||||
- [ ] Modal otwiera się, podgląd rozwiązuje zmienne
|
||||
- [ ] Wysyłka SMTP działa (mail dociera do odbiorcy)
|
||||
- [ ] Załącznik paragon PDF dołączany gdy szablon ma attachment_1='receipt'
|
||||
- [ ] email_logs zapisuje wpis z poprawnym statusem
|
||||
- [ ] Zakładka Dokumenty wyświetla historię emaili
|
||||
- [ ] DOCS zaktualizowane
|
||||
- [ ] Wszystkie acceptance criteria spełnione
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All tasks completed
|
||||
- All verification checks pass
|
||||
- No PHP errors or warnings introduced
|
||||
- E-mail z zamówienia działa end-to-end (szablon → podgląd → wysyłka → log → historia)
|
||||
- Paragon PDF jako załącznik działa (gdy dostępny)
|
||||
- Brak regresji w istniejących modułach (zamówienia, paragony, szablony, skrzynki)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/15-email-sending/15-01-SUMMARY.md`
|
||||
</output>
|
||||
164
.paul/phases/15-email-sending/15-01-SUMMARY.md
Normal file
164
.paul/phases/15-email-sending/15-01-SUMMARY.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
phase: 15-email-sending
|
||||
plan: 01
|
||||
subsystem: email
|
||||
tags: [phpmailer, smtp, dompdf, quill, activity-log]
|
||||
|
||||
requires:
|
||||
- phase: 13-email-mailboxes
|
||||
provides: email_mailboxes table, SMTP credentials, IntegrationSecretCipher
|
||||
- phase: 14-email-templates
|
||||
provides: email_templates table, Quill.js editor, VARIABLE_GROUPS, ATTACHMENT_TYPES
|
||||
|
||||
provides:
|
||||
- EmailSendingService (send + preview via SMTP)
|
||||
- VariableResolver (template variable substitution)
|
||||
- AttachmentGenerator (receipt PDF in-memory)
|
||||
- Send email modal on order detail view
|
||||
- Activity log integration for email events
|
||||
|
||||
affects: []
|
||||
|
||||
tech-stack:
|
||||
added: [phpmailer/phpmailer v7.0.2]
|
||||
patterns: [activity-log-based email history, in-memory PDF attachments]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- src/Modules/Email/EmailSendingService.php
|
||||
- src/Modules/Email/VariableResolver.php
|
||||
- src/Modules/Email/AttachmentGenerator.php
|
||||
- resources/views/orders/partials/email-send-modal.php
|
||||
- resources/scss/modules/_email-send.scss
|
||||
modified:
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- routes/web.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/lang/pl.php
|
||||
|
||||
key-decisions:
|
||||
- "PHPMailer v7.0.2 jako SMTP transport (nie natywny mail())"
|
||||
- "Email history jako wpisy w order_activity_log (nie osobna sekcja UI)"
|
||||
- "VariableResolver wydzielony jako osobna klasa (reuse poza kontrolerem szablonow)"
|
||||
- "Zalaczniki in-memory (addStringAttachment) bez plikow tymczasowych"
|
||||
|
||||
patterns-established:
|
||||
- "Activity log integration: nowe typy zdarzen (email_sent/email_failed) z tlumaczeniami w pl.php"
|
||||
- "Email modul (App\\Modules\\Email) jako oddzielny namespace od Settings"
|
||||
|
||||
duration: ~90min
|
||||
started: 2026-03-17T10:00:00Z
|
||||
completed: 2026-03-17T11:30:00Z
|
||||
---
|
||||
|
||||
# Phase 15 Plan 01: Wysylka e-mail z zamowien — Summary
|
||||
|
||||
**Pelny flow wysylki e-mail z widoku zamowienia: wybor szablonu, podglad ze zmiennymi, wysylka SMTP z zalacznikiem paragon PDF, logowanie w historii zamowienia.**
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~90min |
|
||||
| Tasks | 2 auto + 1 checkpoint |
|
||||
| Files created | 5 |
|
||||
| Files modified | 9 |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: Wysylka e-mail z zamowienia | Pass | Szablon + zmienne + SMTP wysylka dziala end-to-end |
|
||||
| AC-2: Zalacznik paragon PDF | Pass | Dompdf generuje PDF in-memory, dolaczany przez addStringAttachment |
|
||||
| AC-3: Logowanie wysylek | Pass | Wpis w email_logs + order_activity_log (widoczny w zakladce Historia) |
|
||||
| AC-4: Podglad przed wysylka | Pass | AJAX preview z rozwiazanymi zmiennymi + lista zalacznikow |
|
||||
| AC-5: Walidacja i obsluga bledow | Pass | Brak konfiguracji → btn disabled; blad SMTP → status failed + komunikat |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Modul `App\Modules\Email` z 3 klasami: EmailSendingService, VariableResolver, AttachmentGenerator
|
||||
- Modal wysylki na widoku zamowienia z wyborem szablonu, skrzynki, podgladem i wysylka AJAX
|
||||
- Integracja z order_activity_log — wysylka maila pojawia sie jako zdarzenie w historii zamowienia
|
||||
- PHPMailer v7.0.2 jako zaleznosc composer
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/Modules/Email/EmailSendingService.php` | Created | Glowna klasa wysylki: send(), preview(), SMTP transport, logowanie |
|
||||
| `src/Modules/Email/VariableResolver.php` | Created | Zamiana {{grupa.zmienna}} na dane zamowienia/kupujacego/firmy |
|
||||
| `src/Modules/Email/AttachmentGenerator.php` | Created | Generowanie PDF paragonu in-memory przez dompdf |
|
||||
| `resources/views/orders/partials/email-send-modal.php` | Created | Modal: wybor szablonu/skrzynki, podglad, wysylka AJAX |
|
||||
| `resources/scss/modules/_email-send.scss` | Created | Style modala i podgladu |
|
||||
| `src/Modules/Orders/OrdersController.php` | Modified | Dodano sendEmail(), emailPreview(), email deps w konstruktorze |
|
||||
| `routes/web.php` | Modified | Nowe route'y POST send-email/email-preview, wiring EmailSendingService |
|
||||
| `resources/views/orders/show.php` | Modified | Przycisk "Wyslij e-mail", include modala |
|
||||
| `resources/lang/pl.php` | Modified | Tlumaczenia email_sent, email_failed |
|
||||
| `resources/scss/app.scss` | Modified | Import modules/email-send |
|
||||
| `composer.json` | Modified | phpmailer/phpmailer v7.0.2 |
|
||||
| `DOCS/DB_SCHEMA.md` | Modified | Wpis o PHPMailer i module Email |
|
||||
| `DOCS/ARCHITECTURE.md` | Modified | Nowy modul + route'y |
|
||||
| `DOCS/TECH_CHANGELOG.md` | Modified | Changelog Phase 15 |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| PHPMailer v7.0.2 (nie natywny mail()) | Pelna kontrola SMTP, auth, TLS, zalaczniki | Nowa zaleznosc composer |
|
||||
| Email history w activity_log (nie osobna sekcja) | Spojnosc UX — jeden timeline zdarzen | Prostszy widok, mniej kodu |
|
||||
| VariableResolver jako osobna klasa | Reuse logiki zmiennych poza kontrolerem szablonow | Czystsza architektura |
|
||||
| In-memory PDF (addStringAttachment) | Brak plikow tymczasowych na dysku | Prostsze, bezpieczniejsze |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Summary
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Scope change | 1 | Email history przeniesiona z Dokumentow do activity_log |
|
||||
| Auto-fixed | 3 | Bugfixy odkryte podczas pracy |
|
||||
|
||||
**Total impact:** Istotna zmiana UX (lepsza), plus naprawione 3 bugi.
|
||||
|
||||
### Scope Change
|
||||
|
||||
**1. Email history w activity_log zamiast osobnej sekcji w Dokumentach**
|
||||
- **Zmiana:** User zażądał przeniesienia historii e-maili z zakładki Dokumenty do zakładki Historia jako wpisy w activity_log
|
||||
- **Wpływ:** Usunięto loadEmailLogs(), emailLogsList z widoku; dodano recordActivity() w EmailSendingService
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. Search duplicate :search parameter (OrdersRepository)**
|
||||
- **Found during:** Testowanie UI
|
||||
- **Issue:** PDO named parameter `:search` użyty 5x w jednym zapytaniu — wyszukiwanie po nazwisku klienta nie działało
|
||||
- **Fix:** Osobne nazwy parametrów `:s1` do `:s5`
|
||||
- **Files:** `src/Modules/Orders/OrdersRepository.php`
|
||||
|
||||
**2. Migration idempotency (attachment_1)**
|
||||
- **Found during:** Uruchomienie migracji
|
||||
- **Issue:** `ALTER TABLE ADD COLUMN attachment_1` nie miała warunku IF NOT EXISTS
|
||||
- **Fix:** Dodano sprawdzenie information_schema + PREPARE/EXECUTE
|
||||
- **Files:** `database/migrations/20260316_000001_add_attachment1_to_email_templates.sql`
|
||||
|
||||
**3. ReceiptController actor name**
|
||||
- **Found during:** Review activity_log entries
|
||||
- **Issue:** Używał `$user['username']` (nieistniejące pole) zamiast `$user['name']`
|
||||
- **Fix:** Zmieniono na `$user['name'] ?? $user['email']`
|
||||
- **Files:** `src/Modules/Accounting/ReceiptController.php`
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- Milestone v0.4 Modul E-mail kompletny (Phase 13 + 14 + 15)
|
||||
- Pelny flow: skrzynki SMTP → szablony z Quill.js → wysylka z zamowien
|
||||
|
||||
**Concerns:**
|
||||
- Brak kolejki/retry dla nieudanych wysylek (synchroniczna wysylka)
|
||||
- Brak bulk email z listy zamowien
|
||||
|
||||
**Blockers:**
|
||||
- None
|
||||
|
||||
---
|
||||
*Phase: 15-email-sending, Plan: 01*
|
||||
*Completed: 2026-03-17*
|
||||
@@ -11,6 +11,8 @@
|
||||
- `App\Modules\Settings`
|
||||
- `App\Modules\Accounting` (modul paragonow — wystawianie, podglad, druk, PDF, lista, eksport XLSX)
|
||||
- `App\Modules\Settings\EmailMailbox*` (skrzynki pocztowe SMTP — CRUD + test polaczenia)
|
||||
- `App\Modules\Settings\EmailTemplate*` (szablony e-mail — CRUD + Quill.js + zmienne + zalaczniki)
|
||||
- `App\Modules\Email` (wysylka e-mail z zamowien — EmailSendingService, VariableResolver, AttachmentGenerator)
|
||||
|
||||
## Routing
|
||||
- `GET /login`, `POST /login`, `POST /logout`
|
||||
@@ -19,6 +21,8 @@
|
||||
- `GET /orders/list`
|
||||
- `GET /orders/{id}`
|
||||
- `POST /orders/{id}/status`
|
||||
- `POST /orders/{id}/send-email` (wysylka e-mail z zamowienia, AJAX)
|
||||
- `POST /orders/{id}/email-preview` (podglad szablonu z rozwiazanymi zmiennymi, AJAX)
|
||||
- `GET /accounting` (lista paragonow z filtrami i paginacja)
|
||||
- `GET /accounting/export` (eksport XLSX z aktywnymi filtrami)
|
||||
- `GET /users` (redirect do `/settings/users`)
|
||||
|
||||
@@ -96,6 +96,7 @@ Migracje z prefiksem `ensure_` to migracje kompensujące — zostały dodane
|
||||
- 2026-03-15: Dodano migracje `20260315_000055_create_email_templates_table.sql` — tabela szablonow wiadomosci email z FK do email_mailboxes.
|
||||
- 2026-03-15: Dodano migracje `20260315_000056_create_email_logs_table.sql` — tabela logow wyslanych wiadomosci z FK do email_templates, email_mailboxes i indeksami na order_id, status, sent_at.
|
||||
- 2026-03-16: Dodano migracje `20260316_000001_add_attachment1_to_email_templates.sql` — kolumna attachment_1 VARCHAR(50) w email_templates (typ zalacznika, np. 'receipt').
|
||||
- 2026-03-17: Nowa zaleznosc `phpmailer/phpmailer` v7.0.2. Modul `App\Modules\Email` — wysylka e-mail z zamowien, resolwer zmiennych, generowanie zalacznikow PDF. Tabela `email_logs` wykorzystywana do logowania wysylek (bez nowych migracji).
|
||||
|
||||
## Tabele
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Tech Changelog
|
||||
|
||||
## 2026-03-17 (Phase 15 — Wysylka e-mail z zamowien)
|
||||
- Nowa zaleznosc: `phpmailer/phpmailer` v7.0.2 (SMTP transport).
|
||||
- Nowy modul `App\Modules\Email` z 3 klasami:
|
||||
- `EmailSendingService` — wysylka e-mail (send, preview), logowanie do email_logs, resolwer skrzynki (mailboxId → template → default).
|
||||
- `VariableResolver` — zamiana `{{grupa.zmienna}}` na dane zamowienia/kupujacego/adresu/firmy.
|
||||
- `AttachmentGenerator` — generowanie PDF paragonu (dompdf) jako zalacznik in-memory (addStringAttachment).
|
||||
- `OrdersController`: nowe metody `sendEmail()`, `emailPreview()`, `loadEmailLogs()`.
|
||||
- Nowe route'y: `POST /orders/{id}/send-email`, `POST /orders/{id}/email-preview`.
|
||||
- Widok `orders/show.php`: przycisk "Wyslij e-mail" + modal (wybor szablonu/skrzynki, podglad, wysylka AJAX).
|
||||
- Zakladka Dokumenty: sekcja "Wysylki e-mail" z historia wyslanych maili (status, podglad body).
|
||||
- Nowy partial: `resources/views/orders/partials/email-send-modal.php`.
|
||||
- Nowy SCSS: `resources/scss/modules/_email-send.scss` (modal overlay, podglad, style).
|
||||
|
||||
## 2026-03-15 (Phase 13 — DB + Skrzynki pocztowe)
|
||||
- Dodano 3 migracje email: `000054_create_email_mailboxes_table`, `000055_create_email_templates_table`, `000056_create_email_logs_table`.
|
||||
- Nowe klasy: `EmailMailboxController` (index, save, delete, toggleStatus, testConnection), `EmailMailboxRepository` (listAll, findById, save, delete, toggleStatus, listActive).
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"phpoffice/phpspreadsheet": "^5.5"
|
||||
"phpoffice/phpspreadsheet": "^5.5",
|
||||
"phpmailer/phpmailer": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5",
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
ALTER TABLE email_templates ADD COLUMN attachment_1 VARCHAR(50) DEFAULT NULL AFTER mailbox_id;
|
||||
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'email_templates' AND COLUMN_NAME = 'attachment_1');
|
||||
SET @sql = IF(@col_exists = 0, 'ALTER TABLE email_templates ADD COLUMN attachment_1 VARCHAR(50) DEFAULT NULL AFTER mailbox_id', 'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -191,6 +191,8 @@ return [
|
||||
'shipment_label_downloaded' => 'Etykieta pobrana',
|
||||
'shipment_error' => 'Blad przesylki',
|
||||
'receipt_issued' => 'Paragon wystawiony',
|
||||
'email_sent' => 'E-mail wyslany',
|
||||
'email_failed' => 'Blad wysylki e-mail',
|
||||
],
|
||||
'actors' => [
|
||||
'system' => 'System',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use "shared/ui-components";
|
||||
@use "modules/email-send";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
||||
112
resources/scss/modules/_email-send.scss
Normal file
112
resources/scss/modules/_email-send.scss
Normal file
@@ -0,0 +1,112 @@
|
||||
.email-send-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.email-send-modal {
|
||||
background: var(--c-card-bg, #fff);
|
||||
border-radius: 8px;
|
||||
width: 580px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--c-border, #e0e0e0);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--c-text-muted, #888);
|
||||
padding: 0 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--c-text, #333);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__field {
|
||||
margin-bottom: 10px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--c-text-muted, #666);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions-top {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--c-border, #e0e0e0);
|
||||
}
|
||||
}
|
||||
|
||||
.email-send-preview {
|
||||
border: 1px solid var(--c-border, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: var(--c-bg, #fafafa);
|
||||
|
||||
&__subject {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--c-border, #e0e0e0);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
p { margin: 0 0 8px; }
|
||||
}
|
||||
|
||||
&__attachments {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--c-border, #e0e0e0);
|
||||
font-size: 12px;
|
||||
color: var(--c-text-muted, #666);
|
||||
}
|
||||
}
|
||||
187
resources/views/orders/partials/email-send-modal.php
Normal file
187
resources/views/orders/partials/email-send-modal.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
$emailTemplatesList = is_array($emailTemplates ?? null) ? $emailTemplates : [];
|
||||
$emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
|
||||
$buyerEmailAddr = '';
|
||||
foreach ($addressesList as $addr) {
|
||||
if (($addr['address_type'] ?? '') === 'customer' && trim((string) ($addr['email'] ?? '')) !== '') {
|
||||
$buyerEmailAddr = trim((string) $addr['email']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($buyerEmailAddr === '') {
|
||||
foreach ($addressesList as $addr) {
|
||||
$email = trim((string) ($addr['email'] ?? ''));
|
||||
if ($email !== '') {
|
||||
$buyerEmailAddr = $email;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$emailEnabled = $emailTemplatesList !== [] && $emailMailboxesList !== [] && $buyerEmailAddr !== '';
|
||||
?>
|
||||
|
||||
<?php if ($emailEnabled): ?>
|
||||
<div class="email-send-overlay" id="emailSendOverlay" style="display:none">
|
||||
<div class="email-send-modal">
|
||||
<div class="email-send-modal__header">
|
||||
<h3>Wyslij e-mail</h3>
|
||||
<button type="button" class="email-send-modal__close" id="emailSendClose">×</button>
|
||||
</div>
|
||||
<div class="email-send-modal__body">
|
||||
<div class="email-send-modal__field">
|
||||
<label>Odbiorca</label>
|
||||
<input type="text" class="input" value="<?= $e($buyerEmailAddr) ?>" readonly>
|
||||
</div>
|
||||
<div class="email-send-modal__field">
|
||||
<label>Szablon</label>
|
||||
<select class="input" id="emailTemplateSelect">
|
||||
<option value="">-- wybierz szablon --</option>
|
||||
<?php foreach ($emailTemplatesList as $tpl): ?>
|
||||
<option value="<?= $e((string) ($tpl['id'] ?? '')) ?>"><?= $e((string) ($tpl['name'] ?? '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="email-send-modal__field">
|
||||
<label>Skrzynka</label>
|
||||
<select class="input" id="emailMailboxSelect">
|
||||
<option value="">Domyslna z szablonu</option>
|
||||
<?php foreach ($emailMailboxesList as $mbx): ?>
|
||||
<option value="<?= $e((string) ($mbx['id'] ?? '')) ?>">
|
||||
<?= $e((string) ($mbx['name'] ?? '')) ?> (<?= $e((string) ($mbx['sender_email'] ?? '')) ?>)
|
||||
<?= (int) ($mbx['is_default'] ?? 0) === 1 ? ' [domyslna]' : '' ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="email-send-modal__actions-top">
|
||||
<button type="button" class="btn btn--secondary btn--sm" id="emailPreviewBtn" disabled>Podglad</button>
|
||||
</div>
|
||||
<div class="email-send-preview" id="emailPreviewArea" style="display:none">
|
||||
<div class="email-send-preview__subject" id="emailPreviewSubject"></div>
|
||||
<div class="email-send-preview__body" id="emailPreviewBody"></div>
|
||||
<div class="email-send-preview__attachments" id="emailPreviewAttachments" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="email-send-modal__footer">
|
||||
<button type="button" class="btn btn--secondary" id="emailSendCancel">Anuluj</button>
|
||||
<button type="button" class="btn btn--primary" id="emailSendBtn" disabled>Wyslij</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var overlay = document.getElementById('emailSendOverlay');
|
||||
var openBtn = document.getElementById('btn-send-email');
|
||||
var closeBtn = document.getElementById('emailSendClose');
|
||||
var cancelBtn = document.getElementById('emailSendCancel');
|
||||
var previewBtn = document.getElementById('emailPreviewBtn');
|
||||
var sendBtn = document.getElementById('emailSendBtn');
|
||||
var templateSelect = document.getElementById('emailTemplateSelect');
|
||||
var mailboxSelect = document.getElementById('emailMailboxSelect');
|
||||
var previewArea = document.getElementById('emailPreviewArea');
|
||||
var previewSubject = document.getElementById('emailPreviewSubject');
|
||||
var previewBody = document.getElementById('emailPreviewBody');
|
||||
var previewAttachments = document.getElementById('emailPreviewAttachments');
|
||||
var orderId = <?= (int) ($orderId ?? 0) ?>;
|
||||
var csrfToken = <?= json_encode((string) ($csrfToken ?? '')) ?>;
|
||||
|
||||
if (!overlay || !openBtn) return;
|
||||
|
||||
function openModal() { overlay.style.display = 'flex'; }
|
||||
function closeModal() {
|
||||
overlay.style.display = 'none';
|
||||
previewArea.style.display = 'none';
|
||||
templateSelect.value = '';
|
||||
previewBtn.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
}
|
||||
|
||||
openBtn.addEventListener('click', openModal);
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
overlay.addEventListener('click', function (ev) {
|
||||
if (ev.target === overlay) closeModal();
|
||||
});
|
||||
|
||||
templateSelect.addEventListener('change', function () {
|
||||
var hasTemplate = templateSelect.value !== '';
|
||||
previewBtn.disabled = !hasTemplate;
|
||||
sendBtn.disabled = !hasTemplate;
|
||||
previewArea.style.display = 'none';
|
||||
});
|
||||
|
||||
previewBtn.addEventListener('click', function () {
|
||||
var tplId = templateSelect.value;
|
||||
if (!tplId) return;
|
||||
previewBtn.disabled = true;
|
||||
previewBtn.textContent = 'Ladowanie...';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('_token', csrfToken);
|
||||
formData.append('template_id', tplId);
|
||||
|
||||
fetch('/orders/' + orderId + '/email-preview', { method: 'POST', body: formData })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
previewSubject.textContent = 'Temat: ' + (data.subject || '');
|
||||
previewBody.innerHTML = data.body_html || '';
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
previewAttachments.style.display = 'block';
|
||||
previewAttachments.textContent = 'Zalaczniki: ' + data.attachments.join(', ');
|
||||
} else {
|
||||
previewAttachments.style.display = 'none';
|
||||
}
|
||||
previewArea.style.display = 'block';
|
||||
})
|
||||
.catch(function () {
|
||||
previewBody.textContent = 'Blad ladowania podgladu';
|
||||
previewArea.style.display = 'block';
|
||||
})
|
||||
.finally(function () {
|
||||
previewBtn.disabled = false;
|
||||
previewBtn.textContent = 'Podglad';
|
||||
});
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var tplId = templateSelect.value;
|
||||
if (!tplId) return;
|
||||
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = 'Wysylanie...';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('_token', csrfToken);
|
||||
formData.append('template_id', tplId);
|
||||
var mbxId = mailboxSelect.value;
|
||||
if (mbxId) formData.append('mailbox_id', mbxId);
|
||||
|
||||
fetch('/orders/' + orderId + '/send-email', { method: 'POST', body: formData })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.success) {
|
||||
closeModal();
|
||||
if (window.OrderProAlerts) {
|
||||
window.OrderProAlerts.success(data.message || 'E-mail wyslany');
|
||||
}
|
||||
setTimeout(function () { location.reload(); }, 1500);
|
||||
} else {
|
||||
if (window.OrderProAlerts) {
|
||||
window.OrderProAlerts.error(data.message || 'Blad wysylki');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (window.OrderProAlerts) {
|
||||
window.OrderProAlerts.error('Blad polaczenia z serwerem');
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = 'Wyslij';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
@@ -9,6 +9,8 @@ $documentsList = is_array($documents ?? null) ? $documents : [];
|
||||
$notesList = is_array($notes ?? null) ? $notes : [];
|
||||
$receiptsList = is_array($receipts ?? null) ? $receipts : [];
|
||||
$receiptConfigsList = is_array($receiptConfigs ?? null) ? $receiptConfigs : [];
|
||||
$emailTemplatesList = is_array($emailTemplates ?? null) ? $emailTemplates : [];
|
||||
$emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
|
||||
$historyList = is_array($history ?? null) ? $history : [];
|
||||
$activityLogList = is_array($activityLog ?? null) ? $activityLog : [];
|
||||
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
|
||||
@@ -52,6 +54,18 @@ foreach ($addressesList as $address) {
|
||||
<?php if ($receiptConfigsList !== []): ?>
|
||||
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/receipt/create" class="btn btn--secondary">Wystaw paragon</a>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$emailBuyerAddr = '';
|
||||
foreach ($addressesList as $a) {
|
||||
if (trim((string) ($a['email'] ?? '')) !== '') { $emailBuyerAddr = trim((string) $a['email']); break; }
|
||||
}
|
||||
$emailBtnEnabled = $emailTemplatesList !== [] && $emailMailboxesList !== [] && $emailBuyerAddr !== '';
|
||||
?>
|
||||
<?php if ($emailBtnEnabled): ?>
|
||||
<button type="button" class="btn btn--secondary" id="btn-send-email">Wyslij e-mail</button>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn--secondary btn--disabled" title="Skonfiguruj skrzynke i szablony w Ustawieniach">Wyslij e-mail</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn--secondary btn--disabled">Platnosc</button>
|
||||
<button type="button" class="btn btn--secondary btn--disabled">Drukuj</button>
|
||||
<button type="button" class="btn btn--primary btn--disabled">Pakuj</button>
|
||||
@@ -335,6 +349,7 @@ foreach ($addressesList as $address) {
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="order-tab-panel" data-order-tab-panel="shipments">
|
||||
@@ -607,5 +622,9 @@ foreach ($addressesList as $address) {
|
||||
});
|
||||
|
||||
setActiveTab('details');
|
||||
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/partials/email-send-modal.php'; ?>
|
||||
|
||||
@@ -41,6 +41,9 @@ use App\Modules\Settings\EmailMailboxRepository;
|
||||
use App\Modules\Settings\EmailTemplateController;
|
||||
use App\Modules\Settings\EmailTemplateRepository;
|
||||
use App\Modules\Settings\IntegrationSecretCipher;
|
||||
use App\Modules\Email\AttachmentGenerator;
|
||||
use App\Modules\Email\EmailSendingService;
|
||||
use App\Modules\Email\VariableResolver;
|
||||
use App\Modules\Accounting\AccountingController;
|
||||
use App\Modules\Accounting\ReceiptController;
|
||||
use App\Modules\Accounting\ReceiptRepository;
|
||||
@@ -65,7 +68,6 @@ return static function (Application $app): void {
|
||||
$shipmentPackageRepositoryForOrders = new ShipmentPackageRepository($app->db());
|
||||
$receiptConfigRepository = new ReceiptConfigRepository($app->db());
|
||||
$receiptRepository = new ReceiptRepository($app->db());
|
||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository);
|
||||
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator(), $app->orderStatuses());
|
||||
$allegroIntegrationRepository = new AllegroIntegrationRepository(
|
||||
$app->db(),
|
||||
@@ -207,6 +209,17 @@ return static function (Application $app): void {
|
||||
$emailTemplateRepository,
|
||||
$emailMailboxRepository
|
||||
);
|
||||
$variableResolver = new VariableResolver();
|
||||
$attachmentGenerator = new AttachmentGenerator($receiptRepository, $receiptConfigRepository, $template);
|
||||
$emailSendingService = new EmailSendingService(
|
||||
$app->db(),
|
||||
$app->orders(),
|
||||
$emailTemplateRepository,
|
||||
$emailMailboxRepository,
|
||||
$variableResolver,
|
||||
$attachmentGenerator
|
||||
);
|
||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository);
|
||||
$receiptController = new ReceiptController(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -284,6 +297,8 @@ return static function (Application $app): void {
|
||||
$router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]);
|
||||
$router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/send-email', [$ordersController, 'sendEmail'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]);
|
||||
$router->post('/users', [$usersController, 'store'], [$authMiddleware]);
|
||||
$router->get('/settings/users', [$usersController, 'index'], [$authMiddleware]);
|
||||
$router->post('/settings/users', [$usersController, 'store'], [$authMiddleware]);
|
||||
|
||||
@@ -178,7 +178,7 @@ final class ReceiptController
|
||||
'created_by' => is_array($user) ? ($user['id'] ?? null) : null,
|
||||
]);
|
||||
|
||||
$userName = is_array($user) ? (string) ($user['username'] ?? $user['email'] ?? '') : '';
|
||||
$userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'receipt_issued',
|
||||
|
||||
106
src/Modules/Email/AttachmentGenerator.php
Normal file
106
src/Modules/Email/AttachmentGenerator.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Email;
|
||||
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Accounting\ReceiptRepository;
|
||||
use App\Modules\Settings\ReceiptConfigRepository;
|
||||
use Dompdf\Dompdf;
|
||||
|
||||
final class AttachmentGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ReceiptRepository $receipts,
|
||||
private readonly ReceiptConfigRepository $receiptConfigs,
|
||||
private readonly Template $template
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array{filename: string, content: string, mime: string}|null
|
||||
*/
|
||||
public function generate(string $type, array $order): ?array
|
||||
{
|
||||
return match ($type) {
|
||||
'receipt' => $this->generateReceiptPdf($order),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @return array{filename: string, content: string, mime: string}|null
|
||||
*/
|
||||
private function generateReceiptPdf(array $order): ?array
|
||||
{
|
||||
$orderId = (int) ($order['id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$receipts = $this->receipts->findByOrderId($orderId);
|
||||
if ($receipts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$receipt = $receipts[0];
|
||||
$receiptId = (int) ($receipt['id'] ?? 0);
|
||||
|
||||
$fullReceipt = $this->receipts->findById($receiptId);
|
||||
if ($fullReceipt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->buildReceiptViewData($fullReceipt);
|
||||
|
||||
$html = $this->template->render('receipts/print', $data);
|
||||
|
||||
$dompdf = new Dompdf();
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4');
|
||||
$dompdf->render();
|
||||
|
||||
$pdfContent = $dompdf->output();
|
||||
if ($pdfContent === null || $pdfContent === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$filename = str_replace(['/', '\\'], '_', (string) ($fullReceipt['receipt_number'] ?? 'paragon')) . '.pdf';
|
||||
|
||||
return [
|
||||
'filename' => $filename,
|
||||
'content' => $pdfContent,
|
||||
'mime' => 'application/pdf',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $receipt
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildReceiptViewData(array $receipt): array
|
||||
{
|
||||
$seller = json_decode((string) ($receipt['seller_data_json'] ?? '{}'), true);
|
||||
$buyer = ($receipt['buyer_data_json'] ?? null) !== null
|
||||
? json_decode((string) $receipt['buyer_data_json'], true)
|
||||
: null;
|
||||
$items = json_decode((string) ($receipt['items_json'] ?? '[]'), true);
|
||||
|
||||
$configName = '';
|
||||
$config = $this->receiptConfigs->findById((int) ($receipt['config_id'] ?? 0));
|
||||
if ($config !== null) {
|
||||
$configName = (string) ($config['name'] ?? '');
|
||||
}
|
||||
|
||||
return [
|
||||
'receipt' => $receipt,
|
||||
'seller' => is_array($seller) ? $seller : [],
|
||||
'buyer' => is_array($buyer) ? $buyer : null,
|
||||
'items' => is_array($items) ? $items : [],
|
||||
'configName' => $configName,
|
||||
];
|
||||
}
|
||||
}
|
||||
336
src/Modules/Email/EmailSendingService.php
Normal file
336
src/Modules/Email/EmailSendingService.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Email;
|
||||
|
||||
use App\Modules\Orders\OrdersRepository;
|
||||
use App\Modules\Settings\EmailMailboxRepository;
|
||||
use App\Modules\Settings\EmailTemplateRepository;
|
||||
use PDO;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception as PHPMailerException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class EmailSendingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly EmailTemplateRepository $templates,
|
||||
private readonly EmailMailboxRepository $mailboxes,
|
||||
private readonly VariableResolver $variableResolver,
|
||||
private readonly AttachmentGenerator $attachmentGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, error: ?string, log_id: int}
|
||||
*/
|
||||
public function send(int $orderId, int $templateId, ?int $mailboxId = null, ?string $actorName = null): array
|
||||
{
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return ['success' => false, 'error' => 'Zamowienie nie znalezione', 'log_id' => 0];
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
|
||||
$template = $this->templates->findById($templateId);
|
||||
if ($template === null) {
|
||||
return ['success' => false, 'error' => 'Szablon nie znaleziony', 'log_id' => 0];
|
||||
}
|
||||
|
||||
$mailbox = $this->resolveMailbox($mailboxId, $template);
|
||||
if ($mailbox === null) {
|
||||
return ['success' => false, 'error' => 'Brak skonfigurowanej skrzynki SMTP', 'log_id' => 0];
|
||||
}
|
||||
|
||||
$recipientEmail = $this->findRecipientEmail($addresses);
|
||||
if ($recipientEmail === '') {
|
||||
return ['success' => false, 'error' => 'Brak adresu e-mail kupujacego', 'log_id' => 0];
|
||||
}
|
||||
|
||||
$recipientName = $this->findRecipientName($addresses);
|
||||
$companySettings = $this->loadCompanySettings();
|
||||
|
||||
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);
|
||||
$resolvedSubject = $this->variableResolver->resolve((string) ($template['subject'] ?? ''), $variableMap);
|
||||
$resolvedBody = $this->variableResolver->resolve((string) ($template['body_html'] ?? ''), $variableMap);
|
||||
|
||||
$attachments = [];
|
||||
$attachmentType = (string) ($template['attachment_1'] ?? '');
|
||||
if ($attachmentType !== '') {
|
||||
$attachment = $this->attachmentGenerator->generate($attachmentType, $order);
|
||||
if ($attachment !== null) {
|
||||
$attachments[] = $attachment;
|
||||
}
|
||||
}
|
||||
|
||||
$status = 'sent';
|
||||
$errorMessage = null;
|
||||
$sentAt = null;
|
||||
|
||||
try {
|
||||
$this->sendViaSMTP($mailbox, $recipientEmail, $recipientName, $resolvedSubject, $resolvedBody, $attachments);
|
||||
$sentAt = date('Y-m-d H:i:s');
|
||||
} catch (Throwable $e) {
|
||||
$status = 'failed';
|
||||
$errorMessage = $e->getMessage();
|
||||
}
|
||||
|
||||
$logId = $this->logEmail(
|
||||
$templateId,
|
||||
(int) ($mailbox['id'] ?? 0),
|
||||
$orderId,
|
||||
$recipientEmail,
|
||||
$recipientName,
|
||||
$resolvedSubject,
|
||||
$resolvedBody,
|
||||
$attachments,
|
||||
$status,
|
||||
$errorMessage,
|
||||
$sentAt
|
||||
);
|
||||
|
||||
$templateName = (string) ($template['name'] ?? '');
|
||||
$activitySummary = $status === 'sent'
|
||||
? 'Wyslano e-mail "' . $resolvedSubject . '" do ' . $recipientEmail
|
||||
: 'Blad wysylki e-mail "' . $resolvedSubject . '" do ' . $recipientEmail . ': ' . ($errorMessage ?? '');
|
||||
$this->orders->recordActivity(
|
||||
$orderId,
|
||||
'email_' . $status,
|
||||
$activitySummary,
|
||||
['template' => $templateName, 'recipient' => $recipientEmail, 'log_id' => $logId],
|
||||
'user',
|
||||
$actorName
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => $status === 'sent',
|
||||
'error' => $errorMessage,
|
||||
'log_id' => $logId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{subject: string, body_html: string, attachments: list<string>}
|
||||
*/
|
||||
public function preview(int $orderId, int $templateId): array
|
||||
{
|
||||
$details = $this->orders->findDetails($orderId);
|
||||
if ($details === null) {
|
||||
return ['subject' => '', 'body_html' => '<p>Zamowienie nie znalezione</p>', 'attachments' => []];
|
||||
}
|
||||
|
||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||
$addresses = is_array($details['addresses'] ?? null) ? $details['addresses'] : [];
|
||||
|
||||
$template = $this->templates->findById($templateId);
|
||||
if ($template === null) {
|
||||
return ['subject' => '', 'body_html' => '<p>Szablon nie znaleziony</p>', 'attachments' => []];
|
||||
}
|
||||
|
||||
$companySettings = $this->loadCompanySettings();
|
||||
$variableMap = $this->variableResolver->buildVariableMap($order, $addresses, $companySettings);
|
||||
$resolvedSubject = $this->variableResolver->resolve((string) ($template['subject'] ?? ''), $variableMap);
|
||||
$resolvedBody = $this->variableResolver->resolve((string) ($template['body_html'] ?? ''), $variableMap);
|
||||
|
||||
$attachmentNames = [];
|
||||
$attachmentType = (string) ($template['attachment_1'] ?? '');
|
||||
if ($attachmentType !== '') {
|
||||
$attachment = $this->attachmentGenerator->generate($attachmentType, $order);
|
||||
if ($attachment !== null) {
|
||||
$attachmentNames[] = $attachment['filename'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'subject' => $resolvedSubject,
|
||||
'body_html' => $resolvedBody,
|
||||
'attachments' => $attachmentNames,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $template
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function resolveMailbox(?int $mailboxId, ?array $template): ?array
|
||||
{
|
||||
if ($mailboxId !== null && $mailboxId > 0) {
|
||||
$mailbox = $this->mailboxes->findById($mailboxId);
|
||||
if ($mailbox !== null && (int) ($mailbox['is_active'] ?? 0) === 1) {
|
||||
return $mailbox;
|
||||
}
|
||||
}
|
||||
|
||||
$templateMailboxId = (int) ($template['mailbox_id'] ?? 0);
|
||||
if ($templateMailboxId > 0) {
|
||||
$mailbox = $this->mailboxes->findById($templateMailboxId);
|
||||
if ($mailbox !== null && (int) ($mailbox['is_active'] ?? 0) === 1) {
|
||||
return $mailbox;
|
||||
}
|
||||
}
|
||||
|
||||
$active = $this->mailboxes->listActive();
|
||||
foreach ($active as $m) {
|
||||
if ((int) ($m['is_default'] ?? 0) === 1) {
|
||||
return $this->mailboxes->findById((int) $m['id']);
|
||||
}
|
||||
}
|
||||
|
||||
return $active !== [] ? $this->mailboxes->findById((int) $active[0]['id']) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
*/
|
||||
private function findRecipientEmail(array $addresses): string
|
||||
{
|
||||
foreach (['customer', 'delivery', 'invoice'] as $type) {
|
||||
foreach ($addresses as $addr) {
|
||||
if (($addr['address_type'] ?? '') === $type) {
|
||||
$email = trim((string) ($addr['email'] ?? ''));
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false) {
|
||||
return $email;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
*/
|
||||
private function findRecipientName(array $addresses): string
|
||||
{
|
||||
foreach (['customer', 'delivery'] as $type) {
|
||||
foreach ($addresses as $addr) {
|
||||
if (($addr['address_type'] ?? '') === $type) {
|
||||
$name = trim((string) ($addr['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function loadCompanySettings(): array
|
||||
{
|
||||
$stmt = $this->pdo->prepare('SELECT * FROM company_settings LIMIT 1');
|
||||
$stmt->execute();
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($row) ? $row : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $mailbox
|
||||
* @param list<array{filename: string, content: string, mime: string}> $attachments
|
||||
*/
|
||||
private function sendViaSMTP(
|
||||
array $mailbox,
|
||||
string $recipientEmail,
|
||||
string $recipientName,
|
||||
string $subject,
|
||||
string $body,
|
||||
array $attachments
|
||||
): void {
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
$mail->isSMTP();
|
||||
$mail->Host = (string) ($mailbox['smtp_host'] ?? '');
|
||||
$mail->Port = (int) ($mailbox['smtp_port'] ?? 587);
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = (string) ($mailbox['smtp_username'] ?? '');
|
||||
$mail->Password = (string) ($mailbox['smtp_password_decrypted'] ?? '');
|
||||
$mail->CharSet = PHPMailer::CHARSET_UTF8;
|
||||
|
||||
$encryption = (string) ($mailbox['smtp_encryption'] ?? 'tls');
|
||||
if ($encryption === 'tls') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
} elseif ($encryption === 'ssl') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} else {
|
||||
$mail->SMTPSecure = '';
|
||||
$mail->SMTPAutoTLS = false;
|
||||
}
|
||||
|
||||
$senderEmail = (string) ($mailbox['sender_email'] ?? $mailbox['smtp_username'] ?? '');
|
||||
$senderName = (string) ($mailbox['sender_name'] ?? '');
|
||||
$mail->setFrom($senderEmail, $senderName);
|
||||
$mail->addAddress($recipientEmail, $recipientName);
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $body;
|
||||
|
||||
foreach ($attachments as $att) {
|
||||
$mail->addStringAttachment($att['content'], $att['filename'], PHPMailer::ENCODING_BASE64, $att['mime']);
|
||||
}
|
||||
|
||||
$mail->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{filename: string, content: string, mime: string}> $attachments
|
||||
*/
|
||||
private function logEmail(
|
||||
int $templateId,
|
||||
int $mailboxId,
|
||||
int $orderId,
|
||||
string $recipientEmail,
|
||||
string $recipientName,
|
||||
string $subject,
|
||||
string $bodyHtml,
|
||||
array $attachments,
|
||||
string $status,
|
||||
?string $errorMessage,
|
||||
?string $sentAt
|
||||
): int {
|
||||
$attachmentsJson = [];
|
||||
foreach ($attachments as $att) {
|
||||
$attachmentsJson[] = [
|
||||
'name' => $att['filename'],
|
||||
'type' => $att['mime'],
|
||||
];
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare(
|
||||
'INSERT INTO email_logs (
|
||||
template_id, mailbox_id, order_id, recipient_email, recipient_name,
|
||||
subject, body_html, attachments_json, status, error_message, sent_at, created_at
|
||||
) VALUES (
|
||||
:template_id, :mailbox_id, :order_id, :recipient_email, :recipient_name,
|
||||
:subject, :body_html, :attachments_json, :status, :error_message, :sent_at, NOW()
|
||||
)'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
'template_id' => $templateId,
|
||||
'mailbox_id' => $mailboxId,
|
||||
'order_id' => $orderId,
|
||||
'recipient_email' => $recipientEmail,
|
||||
'recipient_name' => $recipientName,
|
||||
'subject' => $subject,
|
||||
'body_html' => $bodyHtml,
|
||||
'attachments_json' => json_encode($attachmentsJson, JSON_UNESCAPED_UNICODE),
|
||||
'status' => $status,
|
||||
'error_message' => $errorMessage,
|
||||
'sent_at' => $sentAt,
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
73
src/Modules/Email/VariableResolver.php
Normal file
73
src/Modules/Email/VariableResolver.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Email;
|
||||
|
||||
final class VariableResolver
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
* @param array<string, mixed> $companySettings
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function buildVariableMap(array $order, array $addresses, array $companySettings): array
|
||||
{
|
||||
$customerAddress = $this->findAddress($addresses, 'customer');
|
||||
$deliveryAddress = $this->findAddress($addresses, 'delivery') ?? $customerAddress;
|
||||
|
||||
$buyerName = (string) ($customerAddress['name'] ?? '');
|
||||
$buyerEmail = (string) ($customerAddress['email'] ?? '');
|
||||
$buyerPhone = (string) ($customerAddress['phone'] ?? '');
|
||||
|
||||
$totalFormatted = number_format((float) ($order['total_with_tax'] ?? 0), 2, ',', ' ');
|
||||
$orderedAt = (string) ($order['ordered_at'] ?? '');
|
||||
if ($orderedAt !== '' && ($ts = strtotime($orderedAt)) !== false) {
|
||||
$orderedAt = date('Y-m-d', $ts);
|
||||
}
|
||||
|
||||
return [
|
||||
'zamowienie.numer' => (string) ($order['internal_order_number'] ?? $order['id'] ?? ''),
|
||||
'zamowienie.numer_zewnetrzny' => (string) ($order['external_order_id'] ?? $order['source_order_id'] ?? ''),
|
||||
'zamowienie.zrodlo' => ucfirst((string) ($order['source'] ?? '')),
|
||||
'zamowienie.kwota' => $totalFormatted,
|
||||
'zamowienie.waluta' => (string) ($order['currency'] ?? 'PLN'),
|
||||
'zamowienie.data' => $orderedAt,
|
||||
'kupujacy.imie_nazwisko' => $buyerName,
|
||||
'kupujacy.email' => $buyerEmail,
|
||||
'kupujacy.telefon' => $buyerPhone,
|
||||
'kupujacy.login' => (string) ($order['customer_login'] ?? ''),
|
||||
'adres.ulica' => trim(($deliveryAddress['street_name'] ?? '') . ' ' . ($deliveryAddress['street_number'] ?? '')),
|
||||
'adres.miasto' => (string) ($deliveryAddress['city'] ?? ''),
|
||||
'adres.kod_pocztowy' => (string) ($deliveryAddress['zip_code'] ?? ''),
|
||||
'adres.kraj' => (string) ($deliveryAddress['country'] ?? ''),
|
||||
'firma.nazwa' => (string) ($companySettings['company_name'] ?? ''),
|
||||
'firma.nip' => (string) ($companySettings['tax_number'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function resolve(string $template, array $variableMap): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\{\{([a-z_]+\.[a-z_]+)\}\}/',
|
||||
static fn(array $m): string => $variableMap[$m[1]] ?? '',
|
||||
$template
|
||||
) ?? $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function findAddress(array $addresses, string $type): ?array
|
||||
{
|
||||
foreach ($addresses as $addr) {
|
||||
if (($addr['address_type'] ?? '') === $type) {
|
||||
return $addr;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use App\Core\Support\Flash;
|
||||
use App\Core\Support\StringHelper;
|
||||
use App\Modules\Accounting\ReceiptRepository;
|
||||
use App\Modules\Auth\AuthService;
|
||||
use App\Modules\Email\EmailSendingService;
|
||||
use App\Modules\Settings\EmailMailboxRepository;
|
||||
use App\Modules\Settings\EmailTemplateRepository;
|
||||
use App\Modules\Settings\ReceiptConfigRepository;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
|
||||
@@ -24,7 +27,10 @@ final class OrdersController
|
||||
private readonly OrdersRepository $orders,
|
||||
private readonly ?ShipmentPackageRepository $shipmentPackages = null,
|
||||
private readonly ?ReceiptRepository $receiptRepo = null,
|
||||
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null
|
||||
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null,
|
||||
private readonly ?EmailSendingService $emailService = null,
|
||||
private readonly ?EmailTemplateRepository $emailTemplateRepo = null,
|
||||
private readonly ?EmailMailboxRepository $emailMailboxRepo = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -178,6 +184,9 @@ final class OrdersController
|
||||
);
|
||||
}
|
||||
|
||||
$emailTemplates = $this->emailTemplateRepo !== null ? $this->emailTemplateRepo->listActive() : [];
|
||||
$emailMailboxes = $this->emailMailboxRepo !== null ? $this->emailMailboxRepo->listActive() : [];
|
||||
|
||||
$flashSuccess = (string) Flash::get('order.success', '');
|
||||
$flashError = (string) Flash::get('order.error', '');
|
||||
|
||||
@@ -206,6 +215,8 @@ final class OrdersController
|
||||
'flashError' => $flashError,
|
||||
'receipts' => $receipts,
|
||||
'receiptConfigs' => $activeReceiptConfigs,
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'emailMailboxes' => $emailMailboxes,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
@@ -676,4 +687,51 @@ final class OrdersController
|
||||
return $entry;
|
||||
}, $history);
|
||||
}
|
||||
|
||||
public function sendEmail(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
if ($orderId <= 0) {
|
||||
return Response::json(['success' => false, 'message' => 'Nieprawidlowe zamowienie'], 400);
|
||||
}
|
||||
|
||||
$csrfToken = (string) $request->input('_token', '');
|
||||
if (!Csrf::validate($csrfToken)) {
|
||||
return Response::json(['success' => false, 'message' => 'Sesja wygasla, odswiez strone'], 403);
|
||||
}
|
||||
|
||||
if ($this->emailService === null) {
|
||||
return Response::json(['success' => false, 'message' => 'Modul e-mail nie jest skonfigurowany'], 500);
|
||||
}
|
||||
|
||||
$templateId = max(0, (int) $request->input('template_id', 0));
|
||||
if ($templateId <= 0) {
|
||||
return Response::json(['success' => false, 'message' => 'Wybierz szablon'], 400);
|
||||
}
|
||||
|
||||
$mailboxId = (int) $request->input('mailbox_id', 0);
|
||||
$user = $this->auth->user();
|
||||
$userName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : '';
|
||||
$result = $this->emailService->send($orderId, $templateId, $mailboxId > 0 ? $mailboxId : null, $userName !== '' ? $userName : null);
|
||||
|
||||
return Response::json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['success'] ? 'E-mail wyslany pomyslnie' : ('Blad wysylki: ' . ($result['error'] ?? 'nieznany')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function emailPreview(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$templateId = max(0, (int) $request->input('template_id', 0));
|
||||
|
||||
if ($orderId <= 0 || $templateId <= 0 || $this->emailService === null) {
|
||||
return Response::json(['subject' => '', 'body_html' => '', 'attachments' => []], 400);
|
||||
}
|
||||
|
||||
$preview = $this->emailService->preview($orderId, $templateId);
|
||||
|
||||
return Response::json($preview);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -95,8 +95,13 @@ final class OrdersRepository
|
||||
|
||||
$search = trim((string) ($filters['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$where[] = '(o.source_order_id LIKE :search OR o.external_order_id LIKE :search OR o.customer_login LIKE :search OR a.name LIKE :search OR a.email LIKE :search)';
|
||||
$params['search'] = '%' . $search . '%';
|
||||
$where[] = '(o.source_order_id LIKE :s1 OR o.external_order_id LIKE :s2 OR o.customer_login LIKE :s3 OR a.name LIKE :s4 OR a.email LIKE :s5)';
|
||||
$searchVal = '%' . $search . '%';
|
||||
$params['s1'] = $searchVal;
|
||||
$params['s2'] = $searchVal;
|
||||
$params['s3'] = $searchVal;
|
||||
$params['s4'] = $searchVal;
|
||||
$params['s5'] = $searchVal;
|
||||
}
|
||||
|
||||
$source = trim((string) ($filters['source'] ?? ''));
|
||||
|
||||
Reference in New Issue
Block a user