[СДЕЛАЙ САМ] Генерация, вставка в PDF и последующее чтение QR-кодов на сайте

Всем привет! Всё как всегда, сделал сам, делюсь с другими. Конструктивная критика приветствуется.

Задача: организовать продажу билетов с онлайн оплатой на массовые мероприятия, организуемые заказчиком. Выбор мест не требуется, ограничения только по количеству билетов. Организовать отправку купленных билетов на почту покупателя в формате pdf. Создать систему проверки билетов по qr-коду. Дать возможность администратору сайта оформлять произвольное количество билетов для продажи на входе. Создавать резерв билетов. Закрывать продажу, при отсутствии билетов.

Нам понадобится:
  1. minishop2;
  2. библиотека jsqr.js для считывания qr-кодов;
  3. библиотека mpdf для генерации pdf;
  4. библиотека PHP QR Code для генерации qr-кодов;
Хочу выразить благодарность @Dmytro Lukianenko за скрипт для считывания qr-кодов, сэкономил кучу времени

Информацию о том как работать с MPDF я брал из официальной документации, она у них, насколько я могу судить, нормальная. О том как работать с PHP QR Code прочитал тут.
С предисловием вроде всё. Переходим к решению.

Первое, что нужно изменить в стандартной логике minishop2, это процесс формирования заказа, поскольку нам не нужна корзина и Билет, хотя и один товара, но он должен иметь уникальный номер и возможность быть активированным отдельно от всего заказа. К тому же заказчик просил записывать дату и время активации, сотрудника который активировал и мало ли что ещё ему(заказчику) придёт в голову потом. Поэтому я принял решение переопределить метод submit класса core/components/minishop2/model/minishop2/msorderhandler.class.php, но при это постарался сохранить стандартную логику, на всякий случай. В местах изменений я оставил комментарии.
<?php
class customOrderHandler extends msOrderHandler
{
    /**
     * @param array $data
     *
     * @return array|string
     */
    public function submit($data = array())
    {
        $response = $this->ms2->invokeEvent('msOnSubmitOrder', array(
            'data' => $data,
            'order' => $this,
        ));
        if (!$response['success']) {
            return $this->error($response['message']);
        }
        if (!empty($response['data']['data'])) {
            $this->set($response['data']['data']);
        }

        $response = $this->getDeliveryRequiresFields();
        if ($this->ms2->config['json_response']) {
            $response = json_decode($response, true);
        }
        if (!$response['success']) {
            return $this->error($response['message']);
        }
        $requires = $response['data']['requires'];

        $errors = array();
        foreach ($requires as $v) {
            if (!empty($v) && empty($this->order[$v])) {
                $errors[] = $v;
            }
        }
        if (!empty($errors)) {
            return $this->error('ms2_order_err_requires', $errors);
        }

        $user_id = $this->ms2->getCustomerId();
        if (empty($user_id) || !is_int($user_id)) {
            return $this->error(is_string($user_id) ? $user_id : 'ms2_err_user_nf');
        }

        $cart_status = $this->ms2->cart->status();
        if (empty($cart_status['total_count']) && empty((int)$_POST['total_count'])) {
            return $this->error('ms2_order_err_empty');
        }

        $delivery_cost = $this->getCost(false, true);
        $cart_cost = $this->getCost(true, true) - $delivery_cost ?: (int)$_POST['total_count'] * (float)$_POST['price']; // изменена логика расчёта стоимости
        $createdon = date('Y-m-d H:i:s');
        /** @var msOrder $order */
        $order = $this->modx->newObject('msOrder');
        $order->fromArray(array(
            'user_id' => $user_id,
            'createdon' => $createdon,
            'num' => $this->getNum(),
            'delivery' => $this->order['delivery'],
            'payment' => $this->order['payment'],
            'cart_cost' => $cart_cost,
            'weight' => $cart_status['total_weight'],
            'delivery_cost' => $delivery_cost,
            'cost' => ($cart_cost + $delivery_cost),
            'status' => 0,
            'context' => $this->ms2->config['ctx']
        ));

        // Adding address
        /** @var msOrderAddress $address */
        $address = $this->modx->newObject('msOrderAddress');
        $address->fromArray(array_merge($this->order, array(
            'user_id' => $user_id,
            'createdon' => $createdon,
        )));

        $order->addOne($address);

        // Adding products
        $cart = $this->ms2->cart->get();
        $products = array();
        if($cart){
            foreach ($cart as $v) {
                if ($tmp = $this->modx->getObject('msProduct', array('id' => $v['id']))) {
                    $name = $tmp->get('pagetitle');
                } else {
                    $name = '';
                }
                /** @var msOrderProduct $product */
                $product = $this->modx->newObject('msOrderProduct');
                $product->fromArray(array_merge($v, array(
                    'product_id' => $v['id'],
                    'name' => $name,
                    'cost' => $v['price'] * $v['count'],
                )));
                $products[] = $product;
            }
        }
        /* custom code  */
        else{
            $resource = $this->modx->getObject('msProduct', array('id' => (int)$_POST['id']));
            $props = $order->get('properties');
            $props['pdf_tpl'] = $resource->get('description');
            $order->set('properties', $props);
            if($resource){
                $price = $resource->get('price');
                $options = array(
                    'activated' => 0,
                    'activatedon' => '',
                    'activatedby' => '',
                    'event_date' => $resource->getTVValue('event_date')
                );
                for($i = 1; $i <= $_POST['total_count']; $i++){
                    $ticketNum = $order->get('num') . '-'. $i;
                    $ticketName = $resource->get('pagetitle') .' №' . $ticketNum;
                    /** @var msOrderProduct $product */
                    $product = $this->modx->newObject('msOrderProduct');
                    $product->fromArray(array(
                        'product_id' => (int)$_POST['id'],
                        'name' => $ticketName,
                        'cost' => $price,
                        'price' => $price,
                        'count' => 1,
                        'options' => $options
                    ));
                    $product->save();
                    $products[] = $product;
                }

            }
        }
        /* custom code  */

        $order->addMany($products);

        $response = $this->ms2->invokeEvent('msOnBeforeCreateOrder', array(
            'msOrder' => $order,
            'order' => $this,
        ));
        if (!$response['success']) {
            return $this->error($response['message']);
        }

        if ($order->save()) {
            $response = $this->ms2->invokeEvent('msOnCreateOrder', array(
                'msOrder' => $order,
                'order' => $this,
            ));
            if (!$response['success']) {
                return $this->error($response['message']);
            }

            /* custom code  */            
            $order->set('comment', $this->modx->getOption('site_url').'?msorder='.$order->get('id'));
            $order->save();
            /* custom code  */

            $this->ms2->cart->clean();
            $this->clean();
            if (empty($_SESSION['minishop2']['orders'])) {
                $_SESSION['minishop2']['orders'] = array();
            }
            $_SESSION['minishop2']['orders'][] = $order->get('id');

            // Trying to set status "new"
            /* custom code  */           
            $status = 1;
            if((int)$_POST['user_id'] > 0){
                $status = 2; // ставим статус Оплачен если заказывает менеджер
            }
            /* custom code  */

            $response = $this->ms2->changeOrderStatus($order->get('id'), $status);
            if ($response !== true) {
                return $this->error($response, array('msorder' => $order->get('id')));
            }

            // Reload order object after changes in changeOrderStatus method
            $order = $this->modx->getObject('msOrder', array('id' => $order->get('id')));

            /** @var msPayment $payment */
            if ($payment = $this->modx->getObject('msPayment',
                array('id' => $order->get('payment'), 'active' => 1))
            ) {
                $response = $payment->send($order);
                if ($this->config['json_response']) {
                    @session_write_close();
                    exit(is_array($response) ? json_encode($response) : $response);
                } else {
                    if (!empty($response['data']['redirect'])) {
                        $this->modx->sendRedirect($response['data']['redirect']);
                    } elseif (!empty($response['data']['msorder'])) {
                        $this->modx->sendRedirect(
                            $this->modx->context->makeUrl(
                                $this->modx->resource->id,
                                array('msorder' => $response['data']['msorder'])
                            )
                        );
                    } else {
                        $this->modx->sendRedirect($this->modx->context->makeUrl($this->modx->resource->id));
                    }

                    return $this->success();
                }
            } else {
                if ($this->ms2->config['json_response']) {
                    return $this->success('', array('msorder' => $order->get('id')));
                } else {
                    $this->modx->sendRedirect(
                        $this->modx->context->makeUrl(
                            $this->modx->resource->id,
                            array('msorder' => $response['data']['msorder'])
                        )
                    );

                    return $this->success();
                }
            }
        }

        return $this->error();
    }
}

Поскольку нам нужно отправить письмо с вложением, а стандартный метод minishop2 так не умеет, отправку писем менеджеру и клиенту в настройках minishop2 отключаем. И создаем сниппет sendTickets.php.
<?php
$order = $modx->getObject('msOrder', $order_id);
if($order){
    $props =$order->get('properties');
    if(!$props['sended'] || ($props['sended'] && $user_id > 0)){
        $tickets = array();
        $products =  $order->getMany('Products');
        $address = $order->getOne('Address');
        $profile =  $modx->getObject('modUserProfile', array('internalKey' => $address->get('user_id')));
        $i = 0;
        foreach($products as $product){
            $ticketName = $product->get('name');
            $options = $product->get('options');
            $ticket = array(
                'name' => $ticketName,
                'receiver' => $address->get('receiver'),
                'email' => $profile->get('email'),
                'phone' => $address->get('phone'),
                'event_date' => $options['event_date'],
                'price' =>  $product->get('price'),
                'id' => $product->get('id'),
                'product_id' => $product->get('product_id')
            );
            $tickets[] = $ticket;
        }

        $pdoTools = $modx->getService('pdoTools');
        $attach = $pdoTools->runSnippet('@FILE snippets/generateTickets.php', array('tickets' => $tickets, 'chunk' => $props['pdf_tpl']));
        if(!$attach){
            return json_encode(array('success' => false, 'message' => 'Не удалось сгенерировать билеты. Обратитесь к администратору сайта.'));
        }

        // получаем ссылку на оплату
        $params['link'] = '';
        if ($payment = $order->getOne('Payment')) {
            if ($class = $payment->get('class')) {
                require_once MODX_CORE_PATH . '/components/minishop2/model/minishop2/minishop2.class.php';
                $modx->initialize('web');
                $ms2 = new miniShop2($modx, array());
                $ms2->loadCustomClasses('payment');
                if (class_exists($class)) {
                    /** @var msPaymentHandler|PayPal $handler */
                    $handler = new $class($order);
                    if (method_exists($handler, 'getPaymentLink')) {
                        $params['link'] = $handler->getPaymentLink($order);
                    }
                }
            }
        }

        $params['order'] = $order->toArray();
        $params['address'] = $address->toArray();
        if(!$pdoTools->runSnippet('@FILE snippets/sendEmail.php', array(
            'chunk' => 'core/elements/chunks/emails/sendTicketEmail.html',
            'params' => $params,
            'to' => $profile->get('email'),
            'attachment' => $attach
        ))){
            return json_encode(array('success' => false, 'message' => 'Билеты не отправлены. Обратитесь к администратору сайта.'));
        };
        $props['sended'] = 1;
        $order->set('properties', $props);
        $order->save();        
        return json_encode(array('success' => true, 'message' => 'Билеты отправлены. До скорой встречи!'));
    }
    else{
        return json_encode(array('success' => false, 'message' => 'Билеты уже были отправлены. Проверьте папку СПАМ.'));
    }
}
else{
    return json_encode(array('success' => false, 'message' => 'Заказ не найден. Обратитесь к администратору сайта.'));
}

Важный момент: чтобы в письмо добавить ссылку на оплату (мало ли что) мы подключаем класс minishop2.
Чтобы избежать повторных отправок, записываем флаг отправки в поле properties заказа, при этом оставляем возможность повторной отправки билетов администраторам, также для этого при расширении класса мы записали ссылку на страницу domain.ru?msorder=1 в поле comment. Чтобы отобразить его в админке, нужно прописать его в системной настройке ms2_order_grid_fields и отредактировать файл assets/components/minishop2/js/mgr/orders/orders.grid.js, а именно на 40 строке, примерно добавить такой код
comment: {width: 100, renderer: function(val, cell, row) {
                return '<a href="'+val+'" target="_blank">Отправить билеты</a>';
            }},


Запускать его мы будем на странице вида domain.ru?msorder=1 после загрузки страницы или на странице успешной оплаты с помощью ajax.
//функция отправки ajax
function sendAjax(path, params, callback) {
    const request = new XMLHttpRequest();
    const url = path || document.location.href;
    request.open('POST', url, true);
    request.setRequestHeader("X-Requested-With", "XMLHttpRequest");
    request.responseType = 'json';
    request.addEventListener('readystatechange', function () {
        if (request.readyState === 4 && request.status === 200) {
            callback(request.response);
        }
    });
    request.send(params);
}

document.addEventListener('DOMContentLoaded', function(){   
    let orderid = document.getElementById('orderid');
    if(orderid){
        let params = new FormData(),
            base = document.querySelector('base').href,
            id = orderid.value,
            userid = document.getElementById('userid').value,
            path = base + 'assets/project_files/ajaxReceiver.php';
        params.append('order_id', id);
        params.append('user_id', userid);
        params.append('action', 'sendTickets');
        sendAjax(path, params, function (response) {           
            document.getElementById('sendresult').innerText = response.message;
        });
    }
});

На странице нужно разместить вот такой код
{set $msorder = $.get.msorder ?: $.cookie.msorder}
{if $msorder}
<input type="hidden" id="orderid" value="{$msorder}">
<input type="hidden" id="userid" value="{$_modx->user.id}">
<script src="assets/project_files/js/common.min.js"></script>
{/if}

Для того, чтобы запрос принять и обработать создаем в папке assets или любом другом доступном с фронта месте файл ajaxReceiver.php
<?php
define('MODX_API_MODE', true);
require_once dirname(dirname(dirname(__FILE__))) . '/index.php';

$modx->getService('error', 'error.modError');
$modx->setLogLevel(modX::LOG_LEVEL_ERROR);
// Откликаться будет ТОЛЬКО на ajax запросы
if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || $_SERVER['HTTP_X_REQUESTED_WITH'] != 'XMLHttpRequest') {return;}

// Сниппет будет обрабатывать не один вид запросов, поэтому работать будем по запрашиваемому действию
// Если в массиве POST нет действия - выход
if (empty($_POST['action'])) {
    return;
}

// А если есть - работаем
$res = '';
$action = $_POST['action'];
$params = json_decode($_POST['params'], 1);
switch ($action) {

    case 'activateTicket':
        if((int)$_POST['user_id'] > 0){
            $id = (int)$_POST['id'];
            if ($ticket = $modx->getObject('msOrderProduct', $id)) {
                $sql = 'SELECT status FROM modxwb_ms2_orders WHERE id = ' . $ticket->get('order_id');
                $statement = $modx->query($sql);
                $status = $statement->fetchAll(PDO::FETCH_COLUMN);
                $options = $ticket->get('options');
                $event_date = strtotime($options['event_date']);
                $now = time();
                $diff = ($event_date - $now) / 3600;
                if($diff < 3 && $diff >= 0){
                    if ($status[0] == 1) {
                        $res = json_encode(array('success' => false, 'message' => 'Билет неоплачен'));
                    } else if ($status[0] == 2 && !$options['activated']) {
                        $options['activated'] = 1;
                        $options['activatedon'] = date('Y-m-d H:i');
                        $options['activatedby'] = $_POST['user_id'];
                        $order = $modx->getObject('msOrder', $ticket->get('order_id'));
                        $order->set('status', 3);
                        $order->save();
                        $res = json_encode(array('success' => true, 'message' => 'Билет активирован'));
                    } else if (($status[0] == 3 || $status[0] == 2) && $options['activated']) {
                        $res = json_encode(array('success' => false, 'message' => 'Повторная активация невозможна'));
                    } else {
                        $res = json_encode(array('success' => false, 'message' => 'Активация невозможна'));
                    }
                }
                else{
                    $res = json_encode(array('success' => false, 'message' => 'Регистрация возможна не ранее чем за 3 часа до начала.'));
                }
            }
            else{
                $res = json_encode(array('success' => false, 'message' => 'Билет не существует'));
            }
        }
        break;

    case 'sendTickets':
        $pdoTools = $modx->getService('pdoTools');
        $res = $pdoTools->runSnippet('@FILE snippets/sendTickets.php', array('order_id' => (int)$_POST['order_id'], 'user_id' => (int)$_POST['user_id']));
        break;

    // А вот сюда потом добавлять новые методы prodFastView
}

// Если у нас есть, что отдать на запрос - отдаем и прерываем работу парсера MODX
if (!empty($res)) {
    die($res);
}

Круг замкнулся и теперь нужно сгенерировать билет. За это отвечает сниппет generateTickets.php
<?php
require_once MODX_CORE_PATH . 'elements/phpqrcode/qrlib.php';
require_once MODX_BASE_PATH . '/vendor/autoload.php';

if (count($tickets)) {
    $qrcodePath = MODX_BASE_PATH . 'attachments/qrcodes/qrcode.png';
    $pdfPath = MODX_BASE_PATH . 'attachments/tickets/tickets.pdf';
    $pdoTools = $modx->getService('pdoTools');

    //подключаем шрифты
    $defaultConfig = (new Mpdf\Config\ConfigVariables())->getDefaults();
    $fontDirs = $defaultConfig['fontDir'];

    $defaultFontConfig = (new Mpdf\Config\FontVariables())->getDefaults();
    $fontData = $defaultFontConfig['fontdata'];

    $mpdf = new \Mpdf\Mpdf([
        'mode' => 'utf-8',
        'format' => 'A4',
        'orientation' => 'P',
        'fontDir' => array_merge($fontDirs, [
            MODX_BASE_PATH . '/assets/project_files/fonts/facefont/',
        ]),
        'fontdata' => $fontData + [
                'catorze27style1' => [
                    'R' => 'catorze27style1-semibold.ttf',
                ]
            ],
        'default_font' => 'catorze27style1'
    ]);

    foreach ($tickets as $k => $v) {
        //генерируем qrCode
        $text = json_encode($v);
        QRcode::png($text, $qrcodePath, 'H', '3px');
        $imageSize = getimagesize($qrcodePath);
        $imageData = base64_encode(file_get_contents($qrcodePath));
        $v['qr'] = "data:{$imageSize['mime']};base64,{$imageData}";

        // добавляем шаблон html
        $html = $pdoTools->parseChunk($chunk, $v);

        // генерируем PDF
        $mpdf->AddPage();
         $mpdf->Image(MODX_BASE_PATH. 'assets/project_files/img/common/ticket.jpg',0,0,210,297,'jpg','',true,false, true);
        $mpdf->WriteHTML($html);
    }

    $mpdf->Output($pdfPath);
    return $pdfPath;
}
Тут стоит пояснить, что получить qr-код и pdf без сохранения в файл у меня не получилось, поэтому к каждому заказу я пересоздаю файлы qrcode.png и tickets.pdf. Если кто-то подскажет как этого избежать, буду благодарен. testTicket.html — это обычный чанк с html кодом, удовлетворяющим требованиям документации mpdf. Функция возвращает путь к созданному файлу.

Ну и наконец непосредственно отправка в сниппите sendEmail.php
<?php
$http_host = $modx->getOption('http_host');
$site_name = $modx->getOption('site_name');
if(!isset($chunk)){
    $modx->log(1, 'Письмо не отправлено. Не передан чанк');
    return false;
}

if(!isset($to)){
    $modx->log(1, 'Письмо не отправлено. Не передан email получателя');
    return false;
}else{
    $to = explode (',',$to);
}
if(!isset($subject)){$subject = 'Вы сделали заказ на сайте '.$site_name;}
if(!isset($from)){$from = 'noreply@'.$http_host;}
if(!isset($reply)){$reply = $from;}
if(!isset($fromName)){$fromName = $http_host;}
if(!isset($params)){
    $params = array();
}elseif(!is_array($params)){
    $params = json_decode($params, 1);
}
$modx->getService('mail', 'mail.modPHPMailer');
$pdoTools = $modx->getService('pdoTools');
$modx->getService('mail', 'mail.modPHPMailer');

$message = $pdoTools->getChunk($chunk, $params);
$modx->mail->set(modMail::MAIL_BODY,$message);
$modx->mail->set(modMail::MAIL_FROM, $from);
$modx->mail->set(modMail::MAIL_FROM_NAME, $fromName);
$modx->mail->set(modMail::MAIL_SUBJECT, $subject);
foreach($to as $t){
    $modx->mail->address('to',$t);
}
$modx->mail->address('reply-to', $reply);
if(isset($attachment)){
    $modx->mail->attach($attachment);
}
$modx->mail->setHTML(true);
if (!$modx->mail->send()) {
    $modx->log(1,'При отправке письма произошла ошибка: '.$modx->mail->mailer->ErrorInfo);
    return false;
}
$modx->mail->reset();
return true;

На этом формирование и отправка билетов закончены, но нужно ещё написать плагин на событие msOnSubmitOrder, который будет проверять есть ли запрошенное пользователем количество билетов или нет. При этом админу доступен резерв.
<?php
if($order_id = (int)$_POST['id']){
    $product = $modx->getObject('modResource', $order_id);
    if($product){
        $maxCount = $product->getTVValue('max_count');
        $reservedCount = $product->getTVValue('reserved_count') ?: 0;
        if($_POST['user_id']){
            $availableCount = $maxCount;
        }
        else{
           $availableCount = $maxCount - $reservedCount; 
        }
        $sql = 'SELECT COUNT(id) FROM modxwb_ms2_order_products WHERE product_id = '.$order_id;
        $statement = $modx->query($sql);
        $result = $statement->fetchAll(PDO::FETCH_COLUMN);
        $count = $result[0];
        if(($count + $_POST['total_count']) > $availableCount){
            $modx->event->output('Осталось билетов: ' . ($availableCount - $count));
        }
        if(($count + $_POST['total_count']) == $availableCount){
            $product->set('published', 0);
            $product->save();
        }
    }
}

Осталось создать страницу для считывания qr-кодов и их проверки. HTML такой
<!-- styles это подключать не обязательно -->
<link rel="stylesheet" href="assets/project_files/libs/node_modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="assets/project_files/libs/node_modules/@fortawesome/fontawesome-free/css/all.min.css">
<link rel="stylesheet" href="assets/project_files/css/style.min.css">

<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>

<div class="container py-4">
    {if $_modx->user.id > 0}
    <div class="row">
        <h1 class="col-12 text-center">{$pagetitle}</h1>

        <div class="col-12">
            <div id="loadingMessage">Unable to access video stream (please make sure you have a webcam enabled)</div>
            <div class="text-center mb-2">
                <canvas id="canvas" hidden style="height:250px;width:250px"></canvas>
            </div>

            <div id="info" class="alert alert-info d-none" role="alert">
                <div class="d-flex flex-column">
                    <h4>Проверяем данные:</h4>
                    <p id="result"></p>
                    <p>
                        <em>Номер билета</em>
                        <span id="name"></span>
                    </p>
                    <p>
                        <em> Дата и время проведения</em>
                        <span id="event_date"></span>
                    </p>
                    <p>
                        <em>ФИО заказчика</em>
                        <span id="receiver"></span>
                    </p>
                    <p>
                        <em>Телефон заказчика</em>
                        <span id="phone"></span>
                    </p>
                    <p>
                        <em>Email заказчика</em>
                        <span id="email"></span>
                    </p>
                    <p id="id" style="position: absolute;opacity: 0;"></p>
                    <input type="hidden" id="user_id" value="{$_modx->user.id}">
                </div>
            </div>
        </div>
    </div>
    {else}
    <div class="row" id="error">
        <h1 class="col-12 text-center">У вас нет прав на активацию</h1>
    </div>
    {/if}
</div>
<script src="assets/project_files/js/jsqr.js"></script> <!-- https://art-sites.ru/assets/art-sites/shared/jsqr.js -->
<script src="assets/project_files/js/common.js"></script> <!-- этот скрипт есть выше -->
<script src="assets/project_files/js/scanqr.js"></script> <!-- https://art-sites.ru/assets/art-sites/shared/scanqr.js -->
Выводить информацию о билете можно по желанию, главное показывать результат проверки. Сам скрипт проверки находится в файле ajaxReceiver.php.

БОНУС сниппет для проверки доступности покупки билетов на фронте, надо же сообщить что билеты проданы.
<?php
$product = $modx->getObject('msProduct', array('template' => 3, 'published' => 1));
if ($product) {
    $event_date = strtotime($product->getTVValue('event_date'));
    $now = time();
    $diff = ($event_date - $now) / 60;
    if ($diff <= 30) { //прекращаем продажу за полчаса до начала
        $product->set('published', 0);
        $product->save();
        return false;
    }
    else {
        $curCount = $modx->getCount('msOrderProducts', array('product_id' => $product->get('id')));
        $maxCount = $product->getTVValue('max_count');
        $availableCount = $maxCount;
        $reservedCount = $product->getTVValue('reserved_count') ?: 0;
        if (!$user_id) { //не позволяем обычному пользователю купить зарезервированные билеты.
            $availableCount = $maxCount - $reservedCount;            
        }
       
        if ($curCount >= $availableCount) {
            return false;
        }
        else {
            return true;
        }
    }
}
UPD: решил ещё выложить шаблон из которого формируется pdf. В нём примечательно то, что никакие размеры кроме тех, что заданы в мм не работают для задания отступов. Второй момент, отступы сверху удалось задать только через padding, а слева только через margin.
<h1>
    {$name | replace: 'Билет ' : ''}
</h1>
<img src="{$qr}" alt="{$name}" width="200" height="200">


<style>
    @page {
        margin-bottom: 0;
        margin-top: 0;
        margin-left: 0;
        margin-right: 0;
    }
    img{
        margin-left: 130mm;
        padding-top: 5mm;
    }
    h1 {
        margin-left: 135mm;
        padding-top: 15mm;
        font-size: 26px;
        color: #3f4d62;
    }
</style>
На этом всё!
Артур Шевченко
22 апреля 2021, 23:53
modx.pro
10
1 711
+14
Поблагодарить автора Отправить деньги

Комментарии: 12

Алексей Шумаев
23 апреля 2021, 08:56
+3
Навскидку несколько моментов:
1. «Чтобы избежать повторных отправок, записываем id заказа в куки… » — лучше таки флаг отправки сохранить в БД, например в properties msOrder. Ибо на страницу domain.ru?msorder=1 можно перейти не раз + она запросто может попасть в индекс яндекса (проверено) и будет весело. Запретите индексацию таких страниц в robots.
2. Откликаться будет ТОЛЬКО на ajax запросы: if ($_SERVER['REQUEST_METHOD'] != 'POST')…
Этот код будет работать при любом запросе к данной странице постом.
Меняйте на что-то вроде:
if(empty($_SERVER['HTTP_X_REQUESTED_WITH']) || $_SERVER['HTTP_X_REQUESTED_WITH'] != 'XMLHttpRequest') die;
3. Отправку писем нужно повесить на событие оформления заказа или иное подходящее.

И я бы такой функционал в принципе не вязал бы с MS2, он тут по факту не нужен )

За публикацию личного опыта — спасибо.
    Артур Шевченко
    23 апреля 2021, 09:09
    0
    Спасибо за комментарии. По первым двум пунктам спорить не буду, особенно по второму. Что касается третьего, изначально так и было, однако отправка письма с вложением при покупке разом больше одного билета вызывала зависание от 3 секунд, возможно это связано с логикой работы ms2, поэтому я решил данный функционал вынести на отдельную страницу, потому как пользователь на неё в любом случае переходит и там он уже ничего не заметит. Если будет сбоить, повешу задачу в крон. А что до ms2 и его нужности, будь к меня скилл повыше, наверное, я его бы не использовал, но с другой стороны ms2 дал почти весь функционал, я ведь совсем немного логику подправил и генерацию билета прикрутил и всё, к тому же ещё оплату подключать, а для ms2 уже есть модули.
      Алексей Шумаев
      23 апреля 2021, 09:13
      0
      Да, именно на крон. Сохраняем очередь отправки в отдельную таблицу, потом кроном раз в ~ 1 мин отправляем накопившиеся и удаляем из таблицы. Лимит на отправку за один запуск скрипта ~ 5-10 писем, чтобы не иметь проблем с хостером (чтобы за спам 100% не принимали).
        Артур Шевченко
        23 апреля 2021, 09:24
        0
        Понимаете какое дело, бо́льшую часть времени крон будет работать в холостую. Получается он должен раз в минуту, пусть даже раз в пять минут запускать скрипт который будет делать запрос в БД, и если есть письмо, отправлять, если нет, то ничего не делать. Но мероприятия маленькие до 500-600 человек, это примерно по 1 письму в час, если продажу начинать за месяц, в лучшем случае. В общем у меня нет каких-то обоснованных аргументов, но сам факт того что скрипт будет срабатывать впустую мне не нравится))) Я лучше пятисекудный таймер добавлю, чтобы ajax точно скрипт запустил типа «Отправляем билеты. Ждите 5...4...3...2...1 Билеты отправлены. До скорой встречи!»)))
          Алексей Шумаев
          23 апреля 2021, 09:39
          0
          Ну да, как вариант — конкретная реализация от задачи.

          Вангую немного на будущее:
          — вдруг проект вырастет?
          — вдруг выяснится, что народ закрывает страницу не дожидаясь отправки и не получает билеты, а потом жалуется?
          — вдруг сбой отправки и вы об этом не узнаете, а повторной отправки не предусмотрено?
          — вдруг надо будет ещё что-то делать вместе с отправкой билета?
          Это я к тому, что лучше этот процесс перенести полностью на бэк, где вы его будете контролировать.

          Чтобы БД не запрашивать (хотя для небольших проектов это не имеет особого значения, да и вообще — кому нужна БД — узкое место же :-) ) — можно в файлы json писать: есть файл -> читаем, получаем указанный заказ, отправляем, удаляем. Если отправка каких-то писем не удалась — пишем в лог и данный файл не удаляем. Однако, имхо, это немного извращение.
            Артур Шевченко
            23 апреля 2021, 09:51
            0
            Вечно эти опытные программисты находят изъяны и не дают насладиться маленьким триумфом от собственного роста? Я понял вас, спасибо. Что до повторной отправки, можно предусмотреть: пишем в поле comment ссылку domain.ru?msorder=2 и выводим это поле в таблице заказов. Логгирование ошибок в скрипте отправки есть, надо только возвращать false и наверное уведомлять админа. Про чтение json это прям сильно))) А вот когда вырастет, тогда можно будет переделать и ещё немного заработать ?
              Алексей Шумаев
              23 апреля 2021, 09:58
              0
              Опыт — сын ошибок трудных (©), делюсь, пока минута есть.
              Подзаработать потом, это хорошо; главное, чтобы проект не возвращался на доработку неожиданно, как будет что-то всплывать. А то техдолг накопится и через пару лет работа будет ради работы делаться. Ну или работать по принципу «сдал проект — и меня нет», тоже так себе подход )))
                Артур Шевченко
                23 апреля 2021, 10:04
                +1
                Нет, я не приемлю принцип «сдал и меня нет», я поддерживаю свои проекты. И стараюсь заранее все нюансы у заказчика узнать, чтобы доработок было минимум. Понятно, что потом аппетиты могут у заказчика вырасти, тогда и переделаем. А за то, что опытом делитесь респект!
    Евгений Шеронов
    23 апреля 2021, 13:05
    +2
    Вроде к каждой статье пишут про безопасность, не?)

    $sql = 'SELECT COUNT(id) FROM modxwb_ms2_order_products WHERE product_id = '.$_POST['id'];
    Ну это прям призыв, чтобы пришли люди и грохнули сайт без каких-либо сложностей))

    $modx->getObject('modResource', $_POST['id']);
    И даже так (и вроде когда используется массив) (обсуждали уже где-то здесь) что-угодно можно пропихнуть.

    Всегда когда работаем с целыми числами всего лишь нужно добавить (int) перед чем-угодно и спать спокойно.

    А это открывает доступ к другим типам инъекций.
    <input type="hidden" id="orderid" value="{$.get.msorder}">
    Возможно, Fenom что-то там и порежет, но надеяться не стоит.
      Артур Шевченко
      23 апреля 2021, 14:07
      0
      Спасибо за уделенное время. Исправления внёс:-)
        Евгений Шеронов
        23 апреля 2021, 14:46
        0
        Пожалуйста, только тут лучше (float), иначе уже бизнес логика может пострадать))
        (int)$_POST['price']
    Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
    12