This commit is contained in:
2026-03-18 00:02:18 +01:00
parent 74230cb7c3
commit a6512cbfa4
23 changed files with 1479 additions and 33 deletions

View File

@@ -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',

View 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,
];
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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'] ?? ''));