ms3PromoCode

Модуль промо-кодов для MiniShop3
Автор дополнения
Николай Савин
Пакетов
20
Закачек
32 486
Обычно отвечает в течение суток
Автор дополнения
Пакетов
20
Закачек
32 486
Обычно отвечает в течение суток
Версия 1.0.0-beta1
Дата выпуска 21.04.2026
Загрузки 0
Просмотры 130
Внимание, этот компонент требует PHP 8.2 или выше!
Внимание, этот компонент требует MODX 3 или выше.

О компоненте


Полнофункциональная система промо-кодов для магазинов на MiniShop3: гибкие правила применения, ручная и пакетная генерация, современная админка на Vue 3 + PrimeVue 4, готовая фронтенд-форма для покупателя. Скидка учитывается прямо в позициях заказа — чек 54-ФЗ, email-уведомления и личный кабинет показывают правильные суммы без каких-либо доработок шаблонов.



Скидки


  • Тип: процент или фиксированная сумма в рублях.
  • Область применения: вся корзина или только подпадающие под правила позиции.
  • Корректный учёт в позициях. Цена со скидкой прописывается в каждой позиции заказа, поэтому сумма в чеке ОФД, в email-уведомлении и в личном кабинете покупателя всегда совпадает с оплаченной — никаких правок шаблонов не требуется.
  • Точная сумма скидки. При распределении по нескольким товарам копейки автоматически компенсируются, чтобы итог совпадал с заявленной суммой до рубля.

Условия применения (правила)


  • Включить или исключить конкретные товары.
  • Ограничить категориями или брендами.
  • Универсальный фильтр по любому полю карточки товара (цена, остаток, артикул, вес, собственные поля и т.д.) с разными операторами сравнения: равно, не равно, больше, меньше, диапазон, содержит и др.
  • Фильтр по опциям товара — цвет, размер, вариант и другие.
  • Несколько правил объединяются по «И», значения внутри одного правила — по «ИЛИ».

Жизненный цикл кода


  • Период действия — даты начала и окончания.
  • Общий лимит применений.
  • Минимальная сумма заказа для применения.
  • Включён / выключен.
  • Автоматические статусы: запланирован, истёк, исчерпан, отключён. Отключённый код для покупателя выглядит как «не найден» — факт его существования не раскрывается.

Генерация кодов


  • Ручное создание одного кода.
  • Пакетная генерация по маске. Синтаксис маски: # — цифра, ? — буква, * — цифра или буква, обычные символы — как есть. Например, SALE-####-?? даст коды вида SALE-4729-KM.
  • Набор символов без похожих друг на друга (нет 0 / O, 1 / I / L) — покупатель не ошибётся при вводе.
  • Защита от дублирования, живое превью в админке.

Админ-панель


Современный интерфейс на Vue 3 + PrimeVue 4.


  • Список промо-кодов с постраничной выборкой, сортировкой, фильтрами и групповыми действиями (включить / выключить / удалить сразу несколько кодов).
  • Редактор кода с тремя вкладками: основные параметры, правила применения, статистика.
  • Раздел «Аналитика»: ключевые показатели (количество применений, общая сумма скидки) и детальный журнал использований с фильтрами по датам и статусу.
  • Вкладка «Промо-код» прямо в карточке заказа — менеджер может применить или снять код у конкретного заказа.

Фронтенд для покупателя


  • Готовая форма ввода кода через сниппет ms3PromoCodeForm. Состояние «код применён» рендерится сразу на сервере — нет «мигания» при загрузке страницы.
  • Шаблон формы легко переопределяется через собственный чанк.
  • Headless JS-API для собственных интеграций (SPA, кастомные темы).
  • Автоматический перерасчёт итогов корзины после применения или снятия кода.
  • Папка переопределений assets/components/ms3promocode-overrides/ — можно заменить любой JS- или CSS-файл пакета своей версией без правок исходников.

Ручное редактирование заказа в админке


  • Если менеджер добавил, изменил или удалил позицию в заказе, к которому уже применён промо-код — скидка автоматически пересчитается и распределится заново.
  • Если новый состав заказа больше не подходит под условия кода (например, сумма стала меньше минимальной) — код автоматически снимается, а факт снятия записывается в историю заказа.

Учёт и статистика


  • Каждое применение фиксируется в базе с привязкой к заказу, покупателю и подробной разбивкой скидки по позициям.
  • Отмена заказа откатывает применение кода и уменьшает счётчик использований. Возврат заказа из отменённого статуса — восстанавливает.
  • Данные учёта доступны в разделе «Аналитика» с возможностью фильтрации и экспорта.

Требования


  • MODX Revolution 3.0 и выше
  • MiniShop3 1.10 и выше
  • PHP 8.2 и выше
  • VueTools — для админки
  • pdoTools — для шаблонов фронтенд-формы

1.0.0-beta1 — 2026-04-21

Первый публичный релиз. Ниже — архитектурные решения, точки расширения и неочевидные моменты, которые стоит знать при сопровождении и развитии кода.

Архитектурная картина

Два слоя данных:

  • msOrder.properties.promo_code — снимок применённого кода (JSON): id, code, discount_type, discount_value, discount_amount, breakdown, original_cart_cost, applied_at.
  • msOrderProduct.properties.ms3promocode — снимок по каждой позиции: original_price, discount_amount, promo_code_id. Ставится только на те позиции, что получили часть скидки.

Модель применения — уценка позиций. Скидка распределяется по matching-позициям пропорционально их стоимости, затем на каждой позиции снижается price (и производится cost = price * count). Оригинальная цена сохраняется в properties.ms3promocode.original_price и восстанавливается при снятии или пересчёте.

Почему так, а не отдельная строка -500 ₽ в итогах:

  • msOrder.cart_cost = SUM(msOrderProduct.cost) у MS3 рассчитывается и в submit, и в finalize. При уценённом price итоги корректны автоматически — никаких дополнительных хуков на msOnGetCartCost не нужно (он был удалён).
  • Чек 54-ФЗ формируется по msOrderProduct.price — и автоматически совпадает с оплаченной суммой по каждой позиции.
  • Email-уведомления и личный кабинет покупателя сразу показывают уценённые цены без доработки шаблонов.

Компенсация копеек. При fixed-скидке и count > 1 округление new_price = target_cost / count может потерять или прибавить копейку. ApplicationService::applyToLineItems() после основного цикла считает leftover и корректирует одну позицию (приоритет — count=1, иначе максимальная уже применённая) так, чтобы итоговая скидка совпала с заявленной до копейки.

Inversion at read-time. ApplicationService::findMatchingItems() при построении breakdown читает оригинальные цены из properties.ms3promocode.original_price, если он есть. Это сохраняет корректность повторной валидации: запуск validate() на корзине с уже применённым кодом вернёт тот же discount_amount, что и при первом apply().

Ключевые сервисы

Все сервисы регистрируются в DI-контейнере через bootstrap.php:

Сервис Ключ DI Назначение
PromoCodeService ms3promocode_promo_code_service CRUD + case-insensitive поиск по коду + инкремент/декремент счётчика.
ValidationService ms3promocode_validation_service Lifecycle (disabled/scheduled/expired/exhausted) + min_order. Возвращает {valid, reason, message}. Сообщения поддерживают плейсхолдеры ([[+min_amount]]).
DiscountCalculator ms3promocode_discount_calculator Чистая логика: расчёт суммы и proportional distribution. Без зависимостей от MODX — тестируется изолированно.
RuleEngine + Rule strategies ms3promocode_rule_engine Проверка matching для scope=matching. 6 стратегий: ProductIds, ExcludeProductIds, ParentIds, VendorIds, ProductData, ProductOption. Секции объединяются по AND, значения внутри — по OR.
CodeGenerator ms3promocode_code_generator Генерация по маске (# цифра, ? буква, * оба, литералы), unambiguous-charset, collision guard.
ApplicationService ms3promocode_application_service Оркестратор: apply / validate / remove / syncAfterCartChange / validateCodeOnly / getCurrent. Фасад для контроллеров и плагина.
UsageTracker ms3promocode_usage_tracker Запись / отмена / восстановление применений в ms3_promo_code_usages + синхронизация денормализованного msPromoCode.used_count.

Интеграция с MiniShop3

Плагинms3promocode.plugin.php. Подписан на:

Событие Что делаем
OnLoadWebDocument Регистрируем JS/CSS фронтенда по пресету ms3promocode.frontend_assets_preset. Честный fallback на overrides-директорию.
msOnAddToCart / msOnChangeInCart / msOnRemoveFromCart Получаем draft через reflection Cart::$draft, вызываем syncAfterCartChange.
msOnCreateOrderProduct / msOnUpdateOrderProduct / msOnRemoveOrderProduct То же самое при ручном редактировании состава заказа через старую (legacy) админку MS3. В новой Vue-админке эти события не дёргаются (см. workaround через Resync ниже).
msOnCreateOrder UsageTracker::recordApplication() — фиксируем применение в ms3_promo_code_usages + инкремент used_count.
msOnChangeOrderStatus Если переход в ms3promocode.cancel_statusescancelApplication + декремент; обратно → reinstateApplication.
msOnManagerCustomCssJs На странице редактирования заказа (page=order) инжектим order-tab.js и window.ms3PromoCodeOrderTabConfig.

Событие msOnGetCartCost не используется — при модели «уценка позиций» вычитать discount ещё раз из агрегата означало бы двойную скидку.

xPDO-кэш связей. Повсюду, где раньше был $order->getMany('Products'), теперь используется $this->loadProducts($order)ApplicationService), который идёт через $modx->getCollection(msOrderProduct::class, ['order_id' => ...]). getMany() кэширует relations — в события msOnAddToCart MS3 передаёт тот же msOrder, что был до мутации, и его кэш устарел на одну позицию.

Workaround: Vue-админка MS3

Новая Vue-админка MS3 ходит на свой OrdersController::addProduct/updateProduct/deleteProduct, который сохраняет msOrderProduct через $modx->newObject()→save() / remove() напрямую, минуя MODX-процессоры и события msOn{Create,Update,Remove}OrderProduct. Отслеживается в modx-pro/MiniShop3#207.

Пока PR не мерджен — в компоненте рабочий обход:

  1. Процессор Mgr/Order/Resync — принимает order_id, проверяет properties.promo_code и вызывает syncAfterCartChange.
  2. В order-tab.js установлен перехват window.fetch. На каждый успешный POST/PUT/PATCH/DELETE по URL-паттерну /api/mgr/orders/{id}/products* (включая закодированный в query-параметре route=` — MS3 заворачивает запросы в MODX-connector) хук делает `await resync(orderId) до возврата response клиенту. Vue после await A.post(...) делает A.get(...) для обновления данных и видит уже пересчитанные цены.
  3. XHR-обёртка оставлена как fallback на случай, если где-то ещё используется XMLHttpRequest.

После мержа upstream — откатываем fetch-hook, оставляем только подписку плагина на события, уже сделанную в плагине.

Web API

  • Router: core/components/ms3promocode/config/routes/web.php.
  • Stub: ставится resolver'ом в core/config/ms3.routes.d/web/50-ms3promocode.php — он require-ит реальный файл из пакета. Это позволяет менять роуты без переустановки пакета.
  • TokenMiddleware / CORS / rate limiting — от общего MS3-entry assets/components/minishop3/api.php.
  • Endpoints: POST /api/v1/promo/apply|remove|validate, GET /api/v1/promo/current.
  • Контроллер PromoCodeController маскирует reason=disabledreason=not_found перед отдачей клиенту, чтобы не раскрывать существование временно отключённого кода. Менеджерский API маскировки не делает.

Manager API

Собственный коннектор assets/components/ms3promocode/connector.php с $processorMap. Процессоры:

  • Mgr/PromoCode/*: GetList / Get / Create / Update / Remove / Generate / PreviewMask.
  • Mgr/Order/*: ApplyPromo / RemovePromo / GetPromo / Resync.
  • Mgr/Product/Search, Mgr/Category/Search, Mgr/Vendor/GetList, Mgr/ProductData/GetFields, Mgr/ProductOption/GetKeys|GetValues — helpers для конструктора правил.
  • Mgr/Analytics/GetKpi|GetLog.

Тонкости Update-процессора: nullable-числа (min_order_amount, date_start, date_end) очищаются пустой строкой. Фронтенд (vueManager/src/services/request.js) для этого отправляет null как пустую строку — без такого преобразования URLSearchParams вообще теряет ключ.

Фронтенд покупателя

  • ms3promocode.headless.js — headless-ядро, глобал window.ms3PromoCode. Единственный _initPromise решает race condition: initialized=true выставляется после _refreshCurrent, чтобы UI-слой не ловил getCurrent() === null до загрузки состояния из сессии.
  • ui/PromoCodeUI.js — опциональный UI, монтируется на [data-ms3-promocode="form"]. По умолчанию после apply/remove делает window.location.reload() (гарантированно обновляет цены позиций в корзине). Отключается data-ms3pc-auto-reload="false" для AJAX-корзин.
  • Сниппет ms3PromoCodeForm отдаёт SSR-hydrated состояние: получает draft через ms3_order_draft_manager + ms3_token_service, читает ApplicationService::getCurrent() и рендерит блок empty либо applied уже с правильным hidden. Нет «мигания» при загрузке страницы.
  • Чанк ms3promocode_form переопределяется через свойство &tpl сниппета.
  • Overrides: любой файл в assets/components/ms3promocode-overrides/ прозрачно заменяет ассет из пакета.

Админ-UI (Vue 3 + PrimeVue 4)

Последние обсуждения в сообществе MODX.pro