Skip to content

feat(core): add shipment lifecycle and delivery webhook - #605

Merged
biz87 merged 6 commits into
betafrom
feat/issue-591-shipment-lifecycle
Sep 14, 2026
Merged

biz87 merged 6 commits into
betafrom
feat/issue-591-shipment-lifecycle

Conversation

@Ibochkarev

@Ibochkarev Ibochkarev commented Aug 19, 2026

Copy link
Copy Markdown
Member

Описание

Добавляет сущность отгрузки (ms3_shipments) и ShipmentLifecycleService, чтобы fulfillment-пакеты вели статус доставки и трек отдельно от справочника msDelivery и от одного только order.status_id.

По умолчанию ms3_shipment_enabled=false. Checkout, submit и ручная смена статуса заказа не меняются. Публичный webhook отвечает 404. Менеджер или пакет может создать shipment и записать tracking_number без смены статуса заказа.

При включённом флаге shipped мапится на ms3_status_sent через OrderStatusService (не прямой status_id). cancelled/failed идут в ms3_status_canceled. in_transit и delivered по умолчанию статус заказа не трогают (сид sent финальный). Падение change() логируется, строка отгрузки уже сохранена.

Публичный callback: POST /api/v1/delivery/webhook/{delivery_id} (без customer token, подпись провайдера). Cost-only DefaultDelivery webhook не принимает. Опциональный ShipmentProviderInterface. HMAC: ShipmentWebhookHmac. Кабинет и Fenom ms3_get_order отдают shipments[] через ShipmentPublicDto без meta/provider/external_id/секретов.

Повтор webhook ищется в ms3_shipment_events (уникальная пара shipment + provider_event_id). Поле last_event_id остаётся кэшем последнего id.

Менеджер: GET/PUT /api/mgr/orders/{id}/shipment (msorder_list / msorder_save) и вкладка «Отслеживание» в карточке заказа.

v1: один shipment на заказ. Реализаций CDEK/Почты/DPD/Яндекса нет.

Тип изменений

  • Исправление бага (non-breaking change)
  • Новая функциональность (non-breaking change)
  • Breaking change (изменение, ломающее обратную совместимость)
  • Рефакторинг (без изменения функциональности)
  • Документация
  • Другое (опишите):

Связанные Issues

Closes #591
Closes #606
Closes #607
Closes #608
Closes #609

Как это было протестировано?

Локальный CI-гейт (без полной установки MODX), PHP lint + vueManager jobs из .github/workflows/ci.yml.

cd core/components/minishop3
php -l  # 647 files, exit 0
composer test:smoke  # 90, exit 0
./vendor/bin/phpunit
# 291 tests, 712 assertions, 12 skipped (@group mysql без DSN), 2 deprecations, exit 0

composer stan:prepare && composer stan
# [OK] No errors, exit 0

cd ../../../vueManager
npm run lint:ci  # exit 0
npx vitest run src/utils/orderPluginTab.test.js  # 1 test, exit 0

composer ci:php через Composer упёрся в process-timeout 300 с. Те же шаги (php -l, smoke, PHPUnit) прошли с exit 0.

  • Ручное тестирование
  • Автоматические тесты (composer test:smoke / composer test, npm run lint:ci, composer stan)
  • Тестирование на разных версиях PHP/MODX

Конфигурация тестирования:

  • MiniShop3: ветка feat/issue-591-shipment-lifecycle от beta
  • MODX: PHPStan deps в .phpstan-deps
  • PHP: 8.4.17

Скриншоты (если применимо)

не приложено

Чеклист

  • Код соответствует стилю проекта
  • Добавлены/обновлены комментарии в сложных местах
  • Изменения не ломают существующую функциональность
  • Лексиконы добавлены на двух языках (ru/en)
  • PHPStan проходит без новых ошибок (composer stan / CI job PHPStan)
  • ESLint проходит без ошибок (npm run lint:ci для Vue)
  • Обновлён CHANGELOG.md (для значимых изменений)

Дополнительные заметки

  • Replay webhook: история ms3_shipment_events. Повтор старого id после нового не вызывает второй change().
  • Fenom: MiniShop3::shipmentPublicForOrder(), без новых $modx->services в сниппете (phpstan ignore.count остаётся 5).
  • @group mysql для PdoShipmentStore пропускается без MS3_TEST_MYSQL_DSN.

@Ibochkarev Ibochkarev added the enhancement New feature or request label Aug 19, 2026
@Ibochkarev
Ibochkarev requested a review from biz87 August 19, 2026 04:55
@AgelxNash AgelxNash mentioned this pull request Sep 6, 2026
16 tasks
AgelxNash pushed a commit to AgelxNash/MiniShop3 that referenced this pull request Sep 6, 2026
Conflict resolution: keep inventory (603) + payment (604) lifecycle additions,
merge shipment events/settings/lexicons alongside them; ms3_get_order combines
PaymentPublicFields (604) with shipments public payload; dedupe ms3_status_sent
lexicon pair (kept from PR 596).
AgelxNash pushed a commit to AgelxNash/MiniShop3 that referenced this pull request Sep 6, 2026
Conflict resolution: PR 640 supersedes merged modx-pro#621 rework (same author, same
intent) — took PR side for 36 files; manually preserved modx-pro#631 useConfirm grids,
modx-pro#623 datefield dialog styles, modx-pro#643 gallery bits, modx-pro#605 order entry; ProductData
sections rebuilt on groupProductDataSections (keeps modx-pro#611/modx-pro#620 sort_order) under
PR 640 Panel layout.
@AgelxNash

Copy link
Copy Markdown

Этот PR включён в тестовую интеграционную сборку всех открытых PR MiniShop3: AgelxNash/MiniShop3, ветка integration/open-prs-20260906 (28/28 открытых).

Сборка нужна, чтобы проверить совместимость взаимозависимых серий PR до их мержа — при последовательном слиянии они конфликтуют друг с другом. Это не ревью и не конкурирующий PR: авторство сохранено (1 PR = 1 коммит с исходным автором), ветка пересобирается по мере обновления PR.

Как вошёл в сборку: Конфликты с #603/#604 разрешены: события/настройки/лексиконы объединены (inventory + shipment + transitions); в ms3_get_order payment отдаётся через PaymentPublicFields (#604) + добавлен shipments; дубликат лексикон-пары setting_ms3_status_sent устранён (оставлена формулировка #596, shipment-поведение описано в дескрипторах shipment-настроек).

@AgelxNash

Copy link
Copy Markdown

Спасибо за PR! Пожелание: скорейшего ревью и мержа 👍 Удачи!

@biz87

biz87 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Проверил. С текущей beta сливается чисто, CI зелёный, включая testbench на трёх версиях MODX; локально smoke, PHPUnit, PHPStan и vitest тоже зелёные. При ms3_shipment_enabled=false оформление и смена статусов не меняются, статус заказа меняется только через OrderStatusService, ShipmentPublicDto отдаёт только разрешённые поля, DefaultDelivery вебхук не принимает. Возвращаю на две правки.

1. Ключ встроенной вкладки tracking

RESERVED_ORDER_TAB_KEYS в vueManager/src/utils/orderPluginTab.js теперь содержит tracking, и регистрация вкладки с этим ключом отклоняется. Но именно key: 'tracking' стоит в примере публичной документации (modx-pro/Docs, docs/components/minishop3/development/order-tabs-integration.md) и до этого PR стоял в докблоке vueManager/src/entries/order.js. Плагин, сделанный по документации, после обновления потеряет вкладку.

Лучше дать встроенной вкладке ключ, который вряд ли занят сторонним кодом, например ms3_shipment, и не резервировать tracking.

2. Одновременные повторы вебхука

В ShipmentLifecycleService::applyProviderEvent() и transition() порядок такой: isReplay() (SELECT по ms3_shipment_events) → msOnBefore*store->update()rememberEvent()syncOrderStatus()msOn*. Транзакции нет.

Два одновременных запроса с одним provider_event_id оба пройдут isReplay() раньше, чем первый запишет событие, и оба выполнят обновление отгрузки, смену статуса заказа и события плагинов. Уникальный индекс защищает только от второй строки в журнале. Назад статус не уедет — переход из финального «Отправлен» запрещён, — но события и уведомления могут сработать дважды. Последовательный повтор работает правильно и покрыт тестом.

Перевозчики шлют повторы с коротким интервалом, поэтому лучше закрыть сразу:

  1. в транзакции сначала вставить событие; конфликт уникальности — значит уже обработано, вернуть текущую строку;
  2. затем msOnBefore* и update(); если плагин отказал — откатить транзакцию, чтобы повтор от провайдера не посчитался обработанным;
  3. commit, и только после него syncOrderStatus() и msOn*.

Порядок вливания линии

После вливания любого из #596, #603, #604 этот PR конфликтует, и наоборот. Предлагаю вливать #605 первым — у него уже есть CI с testbench, — а #604 после этого ребейзить на beta.

Fulfillment packages need a first-class shipment row and tracking without
overloading order status or msDelivery.properties. Keep checkout unchanged
until ms3_shipment_enabled is on.
Keep webhook replay honest across event ids, expose shipment in Fenom and the order editor, and cover PdoShipmentStore on MySQL.
ShipmentLifecycleWiringTest expected a trailing-slash public prefix that
no longer matches TokenMiddleware after the beta rebase. PhinxSchemaLive
must allowlist ms3_shipments / ms3_shipment_events (PDO store, no xPDO map).
Use built-in key ms3_shipment so docs can keep key tracking. Insert
provider_event_id before mutate and roll back on plugin cancel so
concurrent webhook retries cannot double-fire side effects.
@Ibochkarev
Ibochkarev force-pushed the feat/issue-591-shipment-lifecycle branch from 57b6a3f to 76594c2 Compare September 14, 2026 12:04
@Ibochkarev

Copy link
Copy Markdown
Member Author

@biz87 Спасибо — обе правки в tip.

1. Ключ вкладки: встроенная вкладка и RESERVED_ORDER_TAB_KEYSms3_shipment. tracking снова свободен для плагинов по доке. Vitest обновлён.

2. Гонка вебхука: claimEvent() вставляет строку в ms3_shipment_events внутри транзакции до msOnBefore* / update(). Конфликт уникальности → уже обработано, текущая строка. Отказ плагина / ошибка → rollBack() (повтор провайдера не считается обработанным). syncOrderStatus() и msOn* — только после commit(). То же для transition / setTracking с eventId.

Ветка перебазирована на актуальную beta. PHPUnit shipment 35/35, wiring smoke, vitest orderPluginTab — ok.

Stop sharing a by-ref bool across closures; PHPStan treated the
after-commit check as always false. Compare webhook tracking to the
pre-claim shipment value instead.
@biz87

biz87 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Спасибо, обе правки на месте. Встроенная вкладка теперь ms3_shipment, tracking свободен, других упоминаний старого ключа не осталось. Событие захватывается в транзакции до msOnBefore* и update(), а syncOrderStatus() и msOn* вызываются после commit — схема та, что нужна. Но на реальном MODX захват не работает.

Блокер: claimEvent() не видит дубль на соединении MODX

modX::__construct() и modX::loadConfig() принудительно открывают соединение с PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, и driver_options из конфига это не перекрывают. Фабрика передаёт в PdoShipmentStore именно $modx->pdo. В этом режиме execute() при нарушении уникальности возвращает false, PDOException не бросается, и claimEvent() доходит до return true.

Проверил на MODX 3 и MySQL: PdoShipmentStore из tip на $modx->pdo, временная таблица с тем же уникальным ключом, два вызова claimEvent(7, 'evt-shipped') подряд:

errmode=SILENT     first=true second=true  rows=1
errmode=EXCEPTION  first=true second=false rows=1

На сайте повтор вебхука теперь не распознаётся, причём даже последовательный. В предыдущей голове это работало: isReplay() проверял событие через hasEvent(), то есть SELECT. Сейчас повтор shipped заново вызывает msOnBeforeChangeShipmentStatus и msOnChangeShipmentStatus. А повтор старого события после более нового — shipped, потом in_transit, потом снова shipped — получает 409, то есть ровно сценарий из #606.

CI этого не видит: MysqlTestConnection создаёт PDO с ERRMODE_EXCEPTION, юнит-тесты сервиса идут на InMemoryShipmentStore, а в live-сьюте вебхука нет.

Что нужно:

  • в PdoShipmentStore определять дубль независимо от режима ошибок: проверять результат execute() и SQLSTATE 23000 в $stmt->errorInfo(), исключение тоже обрабатывать, остальные ошибки бросать;
  • то же в create(): дубль по uniq_shipment_order сейчас ловится только через исключение. И в update(), где результат execute() не проверяется вовсе — ошибка UPDATE пройдёт молча;
  • не менять ATTR_ERRMODE у $modx->pdo: это общее соединение всего MODX;
  • тест, который это ловит: MySQL-тест хранилища на PDO с ERRMODE_SILENT, как у MODX, или live-тест на $modx->pdo.

PdoPaymentAttemptStore из #604 этим не задет: там событие проверяется через hasEvent() под SELECT ... FOR UPDATE строки попытки.

Заодно, раз правки всё равно нужны

  1. applyWithEventClaim() передаёт в $mutate строку отгрузки, прочитанную до транзакции. Если одновременно пришли два разных события одной отгрузки, оба захвата пройдут. Второе проверит assertTransition() и посчитает transitionTimestamps() по устаревшему статусу, а его update() перезапишет результат первого. Например, из shipped почти одновременно delivered и in_transit — в итоге может остаться in_transit. Достаточно после успешного claimEvent() перечитать строку через SELECT ... FOR UPDATE и передавать в $mutate её.
  2. PdoShipmentStore::commit() и rollBack() не знают, открывали ли они транзакцию. Если сервис вызовут внутри чужой транзакции, beginTransaction() новую не откроет, а commit() или rollBack() завершит чужую. В PdoPaymentAttemptStore::writeWithEvent() из feat(core): add payment attempt lifecycle and webhook callback #604 это решено флагом $started — стоит сделать так же.

MODX opens $modx->pdo with ERRMODE_SILENT, so unique violations return
false from execute() instead of throwing. Treat SQLSTATE 23000 from
errorInfo the same as PDOException, lock-reload after claim, and only
commit/rollBack transactions this store started.
@Ibochkarev

Copy link
Copy Markdown
Member Author

@biz87 Спасибо — блокер и оба сопутствующих пункта в tip (6e223d59).

1. ERRMODE_SILENT: PdoShipmentStore::executeWrite() смотрит и на PDOException, и на execute() === false + SQLSTATE 23000 в $stmt->errorInfo(). ATTR_ERRMODE у $modx->pdo не трогаем. То же для create() (дубль uniq_shipment_order) и update() (молчание execute() больше не глотается).

2. Lock после claim: после успешного claimEvent()findByIdForUpdate(), в $mutate уходит эта строка.

3. Вложенная транзакция: commit() / rollBack() срабатывают только если этот store сам открыл транзакцию ($ownsTransaction), как $started в payment store.

Тесты: MySQL — claimEvent/create при ERRMODE_SILENT, nested txn, FOR UPDATE; юнит — relock после claim. PHPUnit shipment unit 18/18. MySQL @group mysql — в CI.

@biz87
biz87 merged commit 26b06d5 into beta Sep 14, 2026
6 checks passed
@biz87

biz87 commented Sep 14, 2026

Copy link
Copy Markdown
Member

Влито в 26b06d51. Одно замечание на будущее, не блокер.

PdoShipmentStore::$ownsTransaction — состояние общего экземпляра: ms3_shipment_lifecycle регистрируется в контейнере MODX (Pimple) как общий сервис, и хранилище создаётся один раз на запрос.

Если плагин на msOnBeforeChangeShipmentStatus или msOnBeforeUpdateShipmentTracking снова вызовет transition() или setTracking() с eventId, вложенный beginTransaction() новую транзакцию не откроет, но флаг останется true от внешнего вызова. Тогда:

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

Сценарий редкий, исправление простое: пусть beginTransaction() возвращает, открыла ли она транзакцию, а applyWithEventClaim() передаёт это в commit() и rollBack() — как $started в PdoPaymentAttemptStore::writeWithEvent(). Либо счётчик вложенности вместо булева флага.

@Ibochkarev
Ibochkarev deleted the feat/issue-591-shipment-lifecycle branch September 14, 2026 17:22
Ibochkarev added a commit that referenced this pull request Sep 16, 2026
Shipment tab landed from #605 with primevue/* imports and would trip
rejectPrimeVueSubpaths / dual Theme instance after Part A of #701.
Ibochkarev added a commit that referenced this pull request Sep 16, 2026
Shipment tab landed from #605 with primevue/* imports and would trip
rejectPrimeVueSubpaths / dual Theme instance after Part A of #701.
biz87 pushed a commit that referenced this pull request Sep 17, 2026
* feat(vue): Salvage PrimeVue barrel, cache-bust, and i18n

Import PrimeVue from the VueTools barrel so manager screens share one
Theme instance. Reject primevue/* subpaths at Vite build time, bust
vue-dist cache with filemtime, and keep Aura as the default preset.

ModxManagerTheme and getActiveTheme stay out until vueTools#22.

Fixes #701

* fix(vue): Address #702 review — AutoComplete, labels, CSS bust

Replace InputChips with AutoComplete for VueTools 1.1.2 barrel,
translate category published filter via lexicon, cache-bust CSS with
filemtime, and drop unused salvage lexicon keys.

* fix(vue): Barrel-migrate OrderShipmentTab after beta merge

Shipment tab landed from #605 with primevue/* imports and would trip
rejectPrimeVueSubpaths / dual Theme instance after Part A of #701.

* Updatу version VueTools

* fix(vue): Restore InputChips for comboOptions Enter/comma/blur

AutoComplete with typeahead=false dropped comma/blur commits and never
fired suggestions. VueTools 1.2.0 barrels InputChips again; cover the
three commit paths and suggestion load in unit tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

3 participants