From c966e85a0e1715b60453f3af0557666c723bf7fd Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 19 Aug 2026 10:53:26 +0600 Subject: [PATCH 1/2] feat(core): add shipment lifecycle and delivery webhook 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. --- _build/elements/events.php | 6 + _build/elements/settings.php | 20 + .../minishop3/config/ms3.services.example.php | 5 + .../minishop3/config/routes/web.php | 5 + .../minishop3/lexicon/en/default.inc.php | 8 + .../minishop3/lexicon/en/setting.inc.php | 8 + .../minishop3/lexicon/ru/default.inc.php | 8 + .../minishop3/lexicon/ru/setting.inc.php | 8 + .../20260819150000_create_shipments.php | 45 ++ .../Api/Web/DeliveryWebhookController.php | 188 ++++++++ .../src/Controllers/Delivery/Delivery.php | 2 + .../Delivery/ShipmentProviderInterface.php | 26 + .../Delivery/ShipmentWebhookEvent.php | 22 + .../src/Middleware/TokenMiddleware.php | 1 + .../minishop3/src/ServiceRegistry.php | 4 + .../src/ServiceRegistryFactories.php | 10 + .../Customer/CustomerOrderService.php | 23 + .../Services/Shipment/PdoShipmentStore.php | 200 ++++++++ .../Shipment/ShipmentLifecycleException.php | 43 ++ .../Shipment/ShipmentLifecycleService.php | 453 ++++++++++++++++++ .../Services/Shipment/ShipmentPublicDto.php | 31 ++ .../src/Services/Shipment/ShipmentStatus.php | 32 ++ .../Shipment/ShipmentStoreInterface.php | 61 +++ .../Services/Shipment/ShipmentWebhookHmac.php | 40 ++ .../DeliveryPaymentCatalogRoutesTest.php | 2 + .../tests/ShipmentLifecycleWiringTest.php | 110 +++++ .../tests/TokenMiddlewarePublicRoutesTest.php | 1 + .../Api/Web/DeliveryWebhookControllerTest.php | 336 +++++++++++++ .../Shipment/ShipmentLifecycleServiceTest.php | 324 +++++++++++++ .../Shipment/ShipmentWebhookHmacTest.php | 42 ++ core/components/minishop3/tests/bootstrap.php | 1 + .../minishop3/tests/stubs/StubMsOrder.php | 5 + .../tests/support/InMemoryShipmentStore.php | 101 ++++ 33 files changed, 2171 insertions(+) create mode 100644 core/components/minishop3/migrations/20260819150000_create_shipments.php create mode 100644 core/components/minishop3/src/Controllers/Api/Web/DeliveryWebhookController.php create mode 100644 core/components/minishop3/src/Controllers/Delivery/ShipmentProviderInterface.php create mode 100644 core/components/minishop3/src/Controllers/Delivery/ShipmentWebhookEvent.php create mode 100644 core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php create mode 100644 core/components/minishop3/src/Services/Shipment/ShipmentLifecycleException.php create mode 100644 core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php create mode 100644 core/components/minishop3/src/Services/Shipment/ShipmentPublicDto.php create mode 100644 core/components/minishop3/src/Services/Shipment/ShipmentStatus.php create mode 100644 core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php create mode 100644 core/components/minishop3/src/Services/Shipment/ShipmentWebhookHmac.php create mode 100644 core/components/minishop3/tests/ShipmentLifecycleWiringTest.php create mode 100644 core/components/minishop3/tests/Unit/Controllers/Api/Web/DeliveryWebhookControllerTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php create mode 100644 core/components/minishop3/tests/Unit/Services/Shipment/ShipmentWebhookHmacTest.php create mode 100644 core/components/minishop3/tests/support/InMemoryShipmentStore.php diff --git a/_build/elements/events.php b/_build/elements/events.php index fd235f233..9b7a12655 100644 --- a/_build/elements/events.php +++ b/_build/elements/events.php @@ -35,6 +35,12 @@ 'msOnChangeOrderStatus', 'msOnBeforeCreateOrder', 'msOnCreateOrder', + 'msOnBeforeCreateShipment', + 'msOnCreateShipment', + 'msOnBeforeChangeShipmentStatus', + 'msOnChangeShipmentStatus', + 'msOnBeforeUpdateShipmentTracking', + 'msOnUpdateShipmentTracking', 'msOnBeforeMgrCreateOrder', 'msOnMgrCreateOrder', 'msOnBeforeUpdateOrder', diff --git a/_build/elements/settings.php b/_build/elements/settings.php index c8c371cd6..6b1123bc9 100644 --- a/_build/elements/settings.php +++ b/_build/elements/settings.php @@ -296,6 +296,26 @@ 'xtype' => 'numberfield', 'area' => 'ms3_statuses', ], + 'ms3_status_sent' => [ + 'value' => 4, + 'xtype' => 'numberfield', + 'area' => 'ms3_statuses', + ], + 'ms3_shipment_enabled' => [ + 'value' => false, + 'xtype' => 'combo-boolean', + 'area' => 'ms3_statuses', + ], + 'ms3_shipment_on_delivered_status' => [ + 'value' => 0, + 'xtype' => 'numberfield', + 'area' => 'ms3_statuses', + ], + 'ms3_shipment_on_in_transit_status' => [ + 'value' => 0, + 'xtype' => 'numberfield', + 'area' => 'ms3_statuses', + ], 'ms3_customer_cancel_allowed_statuses' => [ 'value' => '2,3', 'xtype' => 'textfield', diff --git a/core/components/minishop3/config/ms3.services.example.php b/core/components/minishop3/config/ms3.services.example.php index b797046ec..40bb16997 100644 --- a/core/components/minishop3/config/ms3.services.example.php +++ b/core/components/minishop3/config/ms3.services.example.php @@ -229,6 +229,11 @@ 'ms3_delivery_service' => [ 'class' => \MyCompany\Delivery\CdekDeliveryService::class, ], + + // External WMS / fulfillment: + // 'ms3_shipment_lifecycle' => [ + // 'class' => \MyCompany\Fulfillment\WmsShipmentLifecycle::class, + // ], */ ]; diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index 39dfd9ed1..a8fa57491 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -315,6 +315,11 @@ $controller = new \MiniShop3\Controllers\Api\Web\DeliveryController($modx); return $controller->getList($params); }); + + $router->post('/webhook/{delivery_id}', function ($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\DeliveryWebhookController($modx); + return $controller->handle($params); + }); }); $router->group('/payment', function ($router) use ($modx) { diff --git a/core/components/minishop3/lexicon/en/default.inc.php b/core/components/minishop3/lexicon/en/default.inc.php index 163378ff0..0c37932a4 100644 --- a/core/components/minishop3/lexicon/en/default.inc.php +++ b/core/components/minishop3/lexicon/en/default.inc.php @@ -187,6 +187,14 @@ $_lang['ms3_err_status_nf'] = 'Status with this identifier not found.'; $_lang['ms3_err_delivery_nf'] = 'Delivery method with this identifier not found.'; $_lang['ms3_err_delivery_id_required'] = 'Delivery ID is required'; +$_lang['ms3_err_shipment_nf'] = 'Shipment not found.'; +$_lang['ms3_err_shipment_disabled'] = 'Shipment lifecycle is disabled.'; +$_lang['ms3_err_shipment_cancelled'] = 'Shipment operation was cancelled.'; +$_lang['ms3_err_shipment_tracking_invalid'] = 'Tracking number is empty.'; +$_lang['ms3_err_shipment_event_conflict'] = 'This shipment event cannot be applied to the current shipment.'; +$_lang['ms3_err_shipment_webhook_unsupported'] = 'This delivery method does not support the core webhook.'; +$_lang['ms3_err_shipment_webhook_invalid'] = 'Delivery callback payload is invalid.'; +$_lang['ms3_err_shipment_webhook_unauthorized'] = 'Delivery callback signature is invalid.'; $_lang['ms3_err_payment_nf'] = 'Payment method with this identifier not found.'; $_lang['ms3_err_payment_id_required'] = 'Payment ID is required'; $_lang['ms3_err_status_final'] = 'Final status is set. It cannot be changed.'; diff --git a/core/components/minishop3/lexicon/en/setting.inc.php b/core/components/minishop3/lexicon/en/setting.inc.php index a82d61935..2bdaf8836 100644 --- a/core/components/minishop3/lexicon/en/setting.inc.php +++ b/core/components/minishop3/lexicon/en/setting.inc.php @@ -139,6 +139,14 @@ $_lang['setting_ms3_status_paid_desc'] = 'What status to set after order payment'; $_lang['setting_ms3_status_canceled'] = 'Canceled order status ID'; $_lang['setting_ms3_status_canceled_desc'] = 'What status to set when canceling order'; +$_lang['setting_ms3_status_sent'] = 'Sent order status ID'; +$_lang['setting_ms3_status_sent_desc'] = 'Order status to set when a shipment becomes shipped (if the transition is allowed).'; +$_lang['setting_ms3_shipment_enabled'] = 'Enable shipment lifecycle'; +$_lang['setting_ms3_shipment_enabled_desc'] = 'Off (default): checkout and order statuses are unchanged. On: shipment shipped maps to ms3_status_sent via OrderStatusService, cancelled/failed maps to ms3_status_canceled. Create/setTracking still work when off. Webhook is 404 when off. Replace ms3_shipment_lifecycle to use an external WMS.'; +$_lang['setting_ms3_shipment_on_delivered_status'] = 'Order status ID on delivered shipment'; +$_lang['setting_ms3_shipment_on_delivered_status_desc'] = 'Optional. 0 (default) keeps order status unchanged when the shipment becomes delivered. Seed sent is final, so leave 0 unless you use a non-final sent status.'; +$_lang['setting_ms3_shipment_on_in_transit_status'] = 'Order status ID on in-transit shipment'; +$_lang['setting_ms3_shipment_on_in_transit_status_desc'] = 'Optional. 0 (default) keeps order status unchanged when the shipment becomes in_transit.'; $_lang['setting_ms3_customer_cancel_allowed_statuses'] = 'Statuses from which customer can cancel order'; $_lang['setting_ms3_customer_cancel_allowed_statuses_desc'] = 'Comma-separated status IDs. Default: New and Paid (2,3). Empty = use ms3_status_new and ms3_status_paid.'; $_lang['setting_ms3_status_for_stat'] = 'Status IDs for statistics'; diff --git a/core/components/minishop3/lexicon/ru/default.inc.php b/core/components/minishop3/lexicon/ru/default.inc.php index 266ae656c..d06b6eb37 100644 --- a/core/components/minishop3/lexicon/ru/default.inc.php +++ b/core/components/minishop3/lexicon/ru/default.inc.php @@ -187,6 +187,14 @@ $_lang['ms3_err_status_nf'] = 'Статус с таким идентификатором не найден.'; $_lang['ms3_err_delivery_nf'] = 'Способ доставки с таким идентификатором не найден.'; $_lang['ms3_err_delivery_id_required'] = 'Не указан ID доставки'; +$_lang['ms3_err_shipment_nf'] = 'Отгрузка не найдена.'; +$_lang['ms3_err_shipment_disabled'] = 'Lifecycle отгрузки выключен.'; +$_lang['ms3_err_shipment_cancelled'] = 'Операция с отгрузкой отменена.'; +$_lang['ms3_err_shipment_tracking_invalid'] = 'Номер отслеживания пустой.'; +$_lang['ms3_err_shipment_event_conflict'] = 'Это событие отгрузки нельзя применить к текущей отгрузке.'; +$_lang['ms3_err_shipment_webhook_unsupported'] = 'Этот способ доставки не поддерживает системный webhook.'; +$_lang['ms3_err_shipment_webhook_invalid'] = 'Тело callback доставки некорректно.'; +$_lang['ms3_err_shipment_webhook_unauthorized'] = 'Подпись callback доставки недействительна.'; $_lang['ms3_err_payment_nf'] = 'Способ оплаты с таким идентификатором не найден.'; $_lang['ms3_err_payment_id_required'] = 'Не указан ID оплаты'; $_lang['ms3_err_status_final'] = 'Установлен финальный статус. Его нельзя менять.'; diff --git a/core/components/minishop3/lexicon/ru/setting.inc.php b/core/components/minishop3/lexicon/ru/setting.inc.php index 88678f604..ff85aadd8 100644 --- a/core/components/minishop3/lexicon/ru/setting.inc.php +++ b/core/components/minishop3/lexicon/ru/setting.inc.php @@ -139,6 +139,14 @@ $_lang['setting_ms3_status_paid_desc'] = 'Какой статус нужно устанавливать после оплаты заказа'; $_lang['setting_ms3_status_canceled'] = 'ID статуса отмены заказа'; $_lang['setting_ms3_status_canceled_desc'] = 'Какой статус нужно устанавливать при отмене заказа'; +$_lang['setting_ms3_status_sent'] = 'ID статуса «отправлен»'; +$_lang['setting_ms3_status_sent_desc'] = 'Статус заказа при переходе отгрузки в shipped, если переход разрешён.'; +$_lang['setting_ms3_shipment_enabled'] = 'Включить lifecycle отгрузки'; +$_lang['setting_ms3_shipment_enabled_desc'] = 'Выкл. (по умолчанию): оформление и статусы заказа как сейчас. Вкл.: shipped ставит ms3_status_sent через OrderStatusService, cancelled/failed — ms3_status_canceled. create/setTracking работают и при выкл. Webhook при выкл. отвечает 404. Внешний WMS подменяется через ms3_shipment_lifecycle.'; +$_lang['setting_ms3_shipment_on_delivered_status'] = 'ID статуса заказа при delivered'; +$_lang['setting_ms3_shipment_on_delivered_status_desc'] = 'Необязательно. 0 (по умолчанию) не меняет статус заказа, когда отгрузка становится delivered. Сид sent финальный, поэтому оставьте 0, если не используете нефинальный sent.'; +$_lang['setting_ms3_shipment_on_in_transit_status'] = 'ID статуса заказа при in_transit'; +$_lang['setting_ms3_shipment_on_in_transit_status_desc'] = 'Необязательно. 0 (по умолчанию) не меняет статус заказа, когда отгрузка становится in_transit.'; $_lang['setting_ms3_customer_cancel_allowed_statuses'] = 'Статусы, из которых покупатель может отменить заказ'; $_lang['setting_ms3_customer_cancel_allowed_statuses_desc'] = 'ID статусов через запятую. По умолчанию: «Новый» и «Оплачен» (2,3). Пусто — использовать ms3_status_new и ms3_status_paid.'; $_lang['setting_ms3_status_for_stat'] = 'ID статусов для статистики'; diff --git a/core/components/minishop3/migrations/20260819150000_create_shipments.php b/core/components/minishop3/migrations/20260819150000_create_shipments.php new file mode 100644 index 000000000..b48aa7345 --- /dev/null +++ b/core/components/minishop3/migrations/20260819150000_create_shipments.php @@ -0,0 +1,45 @@ +hasTable('ms3_shipments')) { + return; + } + + $this->table('ms3_shipments', [ + 'id' => true, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('order_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('delivery_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('status', 'string', ['limit' => 32, 'null' => false, 'default' => 'preparing']) + ->addColumn('tracking_number', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('external_id', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('provider', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('carrier', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('shipped_at', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addColumn('delivered_at', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addColumn('last_event_id', 'string', ['limit' => 191, 'null' => true, 'default' => null]) + ->addColumn('meta', 'text', ['null' => true]) + ->addColumn('createdon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addColumn('updatedon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addIndex(['order_id'], ['unique' => true, 'name' => 'uniq_shipment_order']) + ->addIndex(['delivery_id', 'provider', 'external_id'], [ + 'unique' => true, + 'name' => 'uniq_shipment_external', + ]) + ->create(); + } +} diff --git a/core/components/minishop3/src/Controllers/Api/Web/DeliveryWebhookController.php b/core/components/minishop3/src/Controllers/Api/Web/DeliveryWebhookController.php new file mode 100644 index 000000000..0068c9988 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Api/Web/DeliveryWebhookController.php @@ -0,0 +1,188 @@ +modx->lexicon->load('minishop3:default'); + } + + /** + * POST /api/v1/delivery/webhook/{delivery_id} + * + * @param array $params + */ + public function handle(array $params = []): Response + { + if (!ShipmentLifecycleService::isEnabled($this->modx)) { + return $this->fail('ms3_err_shipment_disabled', HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND); + } + + $deliveryId = (int) ($params['delivery_id'] ?? 0); + if ($deliveryId <= 0) { + return $this->fail('ms3_err_delivery_id_required', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + + $method = $this->modx->getObject(msDelivery::class, ['id' => $deliveryId]); + if (!$method instanceof msDelivery) { + return $this->fail('ms3_err_delivery_nf', HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND); + } + + /** @var DeliveryService $deliveryService */ + $deliveryService = $this->modx->services->get('ms3_delivery_service'); + $handler = $deliveryService->loadDeliveryController($method); + if (!$handler instanceof ShipmentProviderInterface) { + return $this->fail( + 'ms3_err_shipment_webhook_unsupported', + HttpStatus::BAD_REQUEST, + ApiErrorCode::BAD_REQUEST + ); + } + + $rawBody = $this->readRawRequestBody(); + $payload = $this->decodeJsonObject($rawBody); + if ($payload === null) { + return $this->fail('ms3_err_shipment_webhook_invalid', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + $headers = $this->requestHeaders(); + if (!$handler->verifyWebhook($rawBody, $payload, $headers, $method)) { + return $this->fail( + 'ms3_err_shipment_webhook_unauthorized', + HttpStatus::UNAUTHORIZED, + ApiErrorCode::UNAUTHORIZED + ); + } + + $event = $handler->parseWebhook($payload, $headers); + if ($event === null) { + return $this->fail('ms3_err_shipment_webhook_invalid', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + + /** @var ShipmentLifecycleService $lifecycle */ + $lifecycle = $this->modx->services->get('ms3_shipment_lifecycle'); + $class = $method->get('class'); + $provider = is_string($class) && $class !== '' ? $class : $handler::class; + + try { + $shipment = $lifecycle->applyProviderEvent($event, $deliveryId, $provider); + } catch (ShipmentLifecycleException $exception) { + return $this->fromLifecycle($exception); + } catch (\Throwable $exception) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + 'Delivery webhook failed: ' . $exception->getMessage() + ); + + return $this->fail( + 'ms3_err_unknown', + HttpStatus::INTERNAL_SERVER_ERROR, + ApiErrorCode::INTERNAL_ERROR + ); + } + + return Response::success([ + 'shipment_id' => $shipment['id'], + 'status' => $shipment['status'], + 'order_id' => $shipment['order_id'], + 'tracking_number' => $shipment['tracking_number'], + ]); + } + + private function fromLifecycle(ShipmentLifecycleException $exception): Response + { + [$status, $errorCode] = match ($exception->getKind()) { + ShipmentLifecycleException::KIND_CONFLICT => [HttpStatus::CONFLICT, ApiErrorCode::CONFLICT], + ShipmentLifecycleException::KIND_NOT_FOUND => [HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND], + default => [HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST], + }; + + return Response::error( + $this->lexicon($exception->getLexiconKey(), $exception->getPlaceholders(), $exception->getMessage()), + $status, + null, + $errorCode + ); + } + + private function fail(string $message, int $status, string $errorCode): Response + { + return Response::error($this->lexicon($message), $status, null, $errorCode); + } + + /** + * @param array $placeholders + */ + private function lexicon(string $key, array $placeholders = [], ?string $fallback = null): string + { + $message = $this->modx->lexicon($key, $placeholders); + if (is_string($message) && $message !== '') { + return $message; + } + + return $fallback ?? $key; + } + + /** + * @return array|null + */ + private function decodeJsonObject(string $raw): ?array + { + if ($raw === '') { + return null; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded) || $decoded === [] || array_is_list($decoded)) { + return null; + } + + return $decoded; + } + + protected function readRawRequestBody(): string + { + $raw = file_get_contents('php://input'); + + return is_string($raw) ? $raw : ''; + } + + /** + * @return array + */ + private function requestHeaders(): array + { + if (!function_exists('getallheaders')) { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (!is_string($key) || !str_starts_with($key, 'HTTP_')) { + continue; + } + $headers[strtolower(str_replace('_', '-', substr($key, 5)))] = is_scalar($value) ? (string) $value : ''; + } + + return $headers; + } + $normalized = []; + foreach (getallheaders() as $name => $value) { + $normalized[strtolower((string) $name)] = (string) $value; + } + + return $normalized; + } +} diff --git a/core/components/minishop3/src/Controllers/Delivery/Delivery.php b/core/components/minishop3/src/Controllers/Delivery/Delivery.php index 8d05f3977..14113e3a3 100644 --- a/core/components/minishop3/src/Controllers/Delivery/Delivery.php +++ b/core/components/minishop3/src/Controllers/Delivery/Delivery.php @@ -14,6 +14,8 @@ * Provides common functionality for all delivery methods. * Custom providers (CDEK, Russian Post, DPD, etc.) can inherit * this class and override getCost() method for their calculation logic. + * Async tracking/webhooks use optional ShipmentProviderInterface; + * cost-only classes stay on DeliveryProviderInterface only. * * Example of creating a CDEK provider: * ```php diff --git a/core/components/minishop3/src/Controllers/Delivery/ShipmentProviderInterface.php b/core/components/minishop3/src/Controllers/Delivery/ShipmentProviderInterface.php new file mode 100644 index 000000000..d191aa878 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Delivery/ShipmentProviderInterface.php @@ -0,0 +1,26 @@ + $payload + * @param array $headers + */ + public function verifyWebhook(string $rawBody, array $payload, array $headers, msDelivery $method): bool; + + /** + * @param array $payload + * @param array $headers + */ + public function parseWebhook(array $payload, array $headers): ?ShipmentWebhookEvent; +} diff --git a/core/components/minishop3/src/Controllers/Delivery/ShipmentWebhookEvent.php b/core/components/minishop3/src/Controllers/Delivery/ShipmentWebhookEvent.php new file mode 100644 index 000000000..58a7737a9 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Delivery/ShipmentWebhookEvent.php @@ -0,0 +1,22 @@ + $payload + */ + public function __construct( + public readonly string $eventType, + public readonly ?int $orderId = null, + public readonly ?string $externalId = null, + public readonly ?string $trackingNumber = null, + public readonly ?string $providerEventId = null, + public readonly ?string $carrier = null, + public readonly array $payload = [], + ) { + } +} diff --git a/core/components/minishop3/src/Middleware/TokenMiddleware.php b/core/components/minishop3/src/Middleware/TokenMiddleware.php index 5c035a923..85c00a04a 100644 --- a/core/components/minishop3/src/Middleware/TokenMiddleware.php +++ b/core/components/minishop3/src/Middleware/TokenMiddleware.php @@ -41,6 +41,7 @@ class TokenMiddleware implements MiddlewareInterface '/api/v1/category/tree', '/api/v1/delivery/get/', '/api/v1/delivery/list', + '/api/v1/delivery/webhook/', '/api/v1/payment/get/', '/api/v1/payment/list', '/api/v1/customer/token/get', diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 2e1e25643..8cac4e0f9 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -206,6 +206,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Delivery\DeliveryService::class, 'interface' => null, ], + 'ms3_shipment_lifecycle' => [ + 'class' => \MiniShop3\Services\Shipment\ShipmentLifecycleService::class, + 'interface' => null, + ], 'ms3_payment_service' => [ 'class' => \MiniShop3\Services\Payment\PaymentService::class, 'interface' => null, diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 522b9ac88..1d6c10e1b 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -2,6 +2,7 @@ namespace MiniShop3; +use MiniShop3\Services\Shipment\PdoShipmentStore; use MODX\Revolution\modX; /** @@ -50,6 +51,15 @@ public static function map(): array 'ms3_product_image' => $modxOnly(), 'ms3_vendor_service' => $modxOnly(), 'ms3_delivery_service' => $modxOnly(), + 'ms3_shipment_lifecycle' => static function (modX $modx, object $services, string $class): object { + if (!$modx->pdo instanceof \PDO) { + throw new \RuntimeException('ms3_shipment_lifecycle requires MODX PDO'); + } + $prefix = (string) $modx->getOption('table_prefix', null, ''); + $store = new PdoShipmentStore($modx->pdo, $prefix . 'ms3_shipments'); + + return new $class($store, $modx, $services->get('ms3_order_status')); + }, 'ms3_payment_service' => $modxOnly(), 'ms3_payment_link_resolver' => $modxOnly(), 'ms3_order_service' => $modxOnly(), diff --git a/core/components/minishop3/src/Services/Customer/CustomerOrderService.php b/core/components/minishop3/src/Services/Customer/CustomerOrderService.php index 09bf41285..5485ca8f4 100644 --- a/core/components/minishop3/src/Services/Customer/CustomerOrderService.php +++ b/core/components/minishop3/src/Services/Customer/CustomerOrderService.php @@ -7,6 +7,8 @@ use MiniShop3\Model\msOrderProduct; use MiniShop3\Model\msOrderStatus; use MiniShop3\Services\Order\OrderStatusService; +use MiniShop3\Services\Shipment\ShipmentLifecycleService; +use MiniShop3\Services\Shipment\ShipmentPublicDto; use MODX\Revolution\modX; /** @@ -168,6 +170,7 @@ public function getForCustomer(int $customerId, int $orderId): ?array 'products' => $this->formatOrderProducts((int)$order->get('id')), 'delivery' => $this->formatDeliveryOrPayment($order->getOne('Delivery') ?: null), 'payment' => $this->formatDeliveryOrPayment($order->getOne('Payment') ?: null), + 'shipments' => $this->publicShipments((int) $order->get('id')), 'address' => $address instanceof msOrderAddress ? $this->formatAddress($address) : null, ]; } @@ -319,6 +322,26 @@ protected function formatDeliveryOrPayment(?object $entity): ?array ]; } + /** + * @return list> + */ + protected function publicShipments(int $orderId): array + { + if (!$this->modx->services->has('ms3_shipment_lifecycle')) { + return []; + } + $lifecycle = $this->modx->services->get('ms3_shipment_lifecycle'); + if (!$lifecycle instanceof ShipmentLifecycleService) { + return []; + } + $row = $lifecycle->findByOrderId($orderId); + if ($row === null) { + return []; + } + + return [ShipmentPublicDto::fromRow($row)]; + } + protected function formatAddress(msOrderAddress $address): array { return [ diff --git a/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php b/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php new file mode 100644 index 000000000..d7e367c07 --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php @@ -0,0 +1,200 @@ +table = $this->quoteTable($table); + } + + public function create( + int $orderId, + int $deliveryId, + string $status, + ?string $provider, + array $meta = [], + ): array { + $now = time(); + $sql = "INSERT INTO {$this->table} + (order_id, delivery_id, status, provider, meta, createdon, updatedon) + VALUES (:order_id, :delivery_id, :status, :provider, :meta, :createdon, :updatedon)"; + $stmt = $this->prepare($sql); + try { + $stmt->execute([ + 'order_id' => $orderId, + 'delivery_id' => $deliveryId, + 'status' => $status, + 'provider' => $provider, + 'meta' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), + 'createdon' => $now, + 'updatedon' => $now, + ]); + } catch (PDOException $exception) { + if ($this->isDuplicate($exception)) { + $existing = $this->findByOrderId($orderId); + if ($existing !== null) { + return $existing; + } + } + throw $exception; + } + + $id = (int) $this->db->lastInsertId(); + $row = $this->findById($id); + if ($row === null) { + throw new RuntimeException('shipment insert did not persist'); + } + + return $row; + } + + public function update(int $id, array $fields): array + { + $row = $this->findById($id); + if ($row === null) { + throw new RuntimeException('shipment not found'); + } + unset($fields['id'], $fields['createdon']); + if ($fields === []) { + return $row; + } + if (array_key_exists('meta', $fields) && is_array($fields['meta'])) { + $fields['meta'] = json_encode($fields['meta'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + $fields['updatedon'] = time(); + $sets = []; + $params = ['id' => $id]; + foreach ($fields as $key => $value) { + if (!preg_match('/^[a-z_]+$/', (string) $key)) { + continue; + } + $sets[] = "`{$key}` = :{$key}"; + $params[$key] = $value; + } + $sql = 'UPDATE ' . $this->table . ' SET ' . implode(', ', $sets) . ' WHERE id = :id'; + $this->prepare($sql)->execute($params); + $updated = $this->findById($id); + if ($updated === null) { + throw new RuntimeException('shipment update did not persist'); + } + + return $updated; + } + + public function findById(int $id): ?array + { + return $this->fetchOne("SELECT * FROM {$this->table} WHERE id = :id", ['id' => $id]); + } + + public function findByOrderId(int $orderId): ?array + { + return $this->fetchOne("SELECT * FROM {$this->table} WHERE order_id = :order_id", ['order_id' => $orderId]); + } + + public function findByExternalId(string $provider, string $externalId, ?int $deliveryId = null): ?array + { + $sql = "SELECT * FROM {$this->table} WHERE provider = :provider AND external_id = :external_id"; + $params = ['provider' => $provider, 'external_id' => $externalId]; + if ($deliveryId !== null) { + $sql .= ' AND delivery_id = :delivery_id'; + $params['delivery_id'] = $deliveryId; + } + + return $this->fetchOne($sql, $params); + } + + /** + * @param array $params + * @return ShipmentRow|null + */ + private function fetchOne(string $sql, array $params): ?array + { + $stmt = $this->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return is_array($row) ? $this->hydrate($row) : null; + } + + /** + * @param array $row + * @return ShipmentRow + */ + private function hydrate(array $row): array + { + $meta = $row['meta'] ?? []; + if (is_string($meta) && $meta !== '') { + $decoded = json_decode($meta, true); + $meta = is_array($decoded) ? $decoded : []; + } + if (!is_array($meta)) { + $meta = []; + } + + return [ + 'id' => (int) $row['id'], + 'order_id' => (int) $row['order_id'], + 'delivery_id' => (int) $row['delivery_id'], + 'status' => (string) $row['status'], + 'tracking_number' => $this->nullableString($row['tracking_number'] ?? null), + 'external_id' => $this->nullableString($row['external_id'] ?? null), + 'provider' => $this->nullableString($row['provider'] ?? null), + 'carrier' => $this->nullableString($row['carrier'] ?? null), + 'shipped_at' => isset($row['shipped_at']) ? (int) $row['shipped_at'] : null, + 'delivered_at' => isset($row['delivered_at']) ? (int) $row['delivered_at'] : null, + 'last_event_id' => $this->nullableString($row['last_event_id'] ?? null), + 'meta' => $meta, + 'createdon' => (int) ($row['createdon'] ?? 0), + 'updatedon' => (int) ($row['updatedon'] ?? 0), + ]; + } + + private function nullableString(mixed $value): ?string + { + return isset($value) && $value !== '' ? (string) $value : null; + } + + private function isDuplicate(PDOException $exception): bool + { + $sqlState = $exception->errorInfo[0] ?? $exception->getCode(); + + return (string) $sqlState === '23000'; + } + + private function prepare(string $sql): PDOStatement + { + $stmt = $this->db->prepare($sql); + if (!$stmt instanceof PDOStatement) { + throw new RuntimeException('Shipment store failed to prepare SQL'); + } + + return $stmt; + } + + private function quoteTable(string $table): string + { + $bare = str_replace('`', '', $table); + if ($bare === '' || !preg_match('/^[A-Za-z0-9_]+$/', $bare)) { + throw new InvalidArgumentException('Invalid shipment table name'); + } + + return '`' . $bare . '`'; + } +} diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleException.php b/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleException.php new file mode 100644 index 000000000..0b00f0815 --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleException.php @@ -0,0 +1,43 @@ + $placeholders + */ + public function __construct( + private readonly string $lexiconKey, + private readonly array $placeholders = [], + private readonly string $kind = self::KIND_INVALID, + ) { + parent::__construct($lexiconKey); + } + + public function getLexiconKey(): string + { + return $this->lexiconKey; + } + + /** + * @return array + */ + public function getPlaceholders(): array + { + return $this->placeholders; + } + + public function getKind(): string + { + return $this->kind; + } +} diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php b/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php new file mode 100644 index 000000000..fe05bf48e --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php @@ -0,0 +1,453 @@ + [ + ShipmentStatus::PREPARING, + ShipmentStatus::SHIPPED, + ShipmentStatus::CANCELLED, + ShipmentStatus::FAILED, + ], + ShipmentStatus::SHIPPED => [ + ShipmentStatus::SHIPPED, + ShipmentStatus::IN_TRANSIT, + ShipmentStatus::DELIVERED, + ShipmentStatus::CANCELLED, + ShipmentStatus::FAILED, + ShipmentStatus::RETURNED, + ], + ShipmentStatus::IN_TRANSIT => [ + ShipmentStatus::IN_TRANSIT, + ShipmentStatus::DELIVERED, + ShipmentStatus::FAILED, + ShipmentStatus::RETURNED, + ], + ShipmentStatus::DELIVERED => [ + ShipmentStatus::DELIVERED, + ShipmentStatus::RETURNED, + ], + ShipmentStatus::CANCELLED => [ShipmentStatus::CANCELLED], + ShipmentStatus::FAILED => [ShipmentStatus::FAILED], + ShipmentStatus::RETURNED => [ShipmentStatus::RETURNED], + ]; + + private const BLOCKED_META_KEYS = [ + 'password', + 'secret', + 'token', + 'api_key', + 'secret_key', + 'properties', + 'class', + 'authorization', + ]; + + public function __construct( + private readonly ShipmentStoreInterface $store, + private readonly modX $modx, + private readonly OrderStatusService $orderStatus, + ) { + } + + public static function isEnabled(modX $modx): bool + { + $value = $modx->getOption('ms3_shipment_enabled', null, false); + + return $value === true || $value === 1 || $value === '1' || $value === 'true'; + } + + /** + * @param array $meta + * @return ShipmentRow + */ + public function create(int $orderId, array $meta = []): array + { + $existing = $this->store->findByOrderId($orderId); + if ($existing !== null) { + return $existing; + } + $order = $this->modx->getObject(msOrder::class, ['id' => $orderId]); + if (!$order instanceof msOrder) { + throw new ShipmentLifecycleException( + 'ms3_err_order_nf', + ['order_id' => $orderId], + ShipmentLifecycleException::KIND_NOT_FOUND + ); + } + $deliveryId = (int) $order->get('delivery_id'); + if ($deliveryId <= 0) { + throw new ShipmentLifecycleException('ms3_err_delivery_id_required'); + } + if (!$this->fire('msOnBeforeCreateShipment', ['order_id' => $orderId, 'delivery_id' => $deliveryId])) { + throw new ShipmentLifecycleException('ms3_err_shipment_cancelled'); + } + $row = $this->store->create( + $orderId, + $deliveryId, + ShipmentStatus::PREPARING, + $this->providerFromOrder($order), + $this->sanitizeMeta($meta) + ); + $this->fire('msOnCreateShipment', ['shipment' => $row]); + + return $row; + } + + /** + * @return ShipmentRow|null + */ + public function findByOrderId(int $orderId): ?array + { + return $this->store->findByOrderId($orderId); + } + + /** + * @return ShipmentRow + */ + public function setTracking(int $shipmentId, string $trackingNumber, ?string $eventId = null): array + { + $shipment = $this->requireShipment($shipmentId); + $trackingNumber = trim($trackingNumber); + if ($trackingNumber === '') { + throw new ShipmentLifecycleException('ms3_err_shipment_tracking_invalid'); + } + if ($this->isReplay($shipment, $eventId)) { + return $shipment; + } + if (!$this->fire('msOnBeforeUpdateShipmentTracking', [ + 'shipment' => $shipment, + 'tracking_number' => $trackingNumber, + ])) { + throw new ShipmentLifecycleException('ms3_err_shipment_cancelled'); + } + $fields = ['tracking_number' => $trackingNumber]; + if ($this->isNonEmpty($eventId)) { + $fields['last_event_id'] = $eventId; + } + $updated = $this->store->update($shipmentId, $fields); + $this->fire('msOnUpdateShipmentTracking', ['shipment' => $updated]); + + return $updated; + } + + /** + * @return ShipmentRow + */ + public function transition(int $shipmentId, string $target, ?string $eventId = null): array + { + $shipment = $this->requireShipment($shipmentId); + if ($this->isReplay($shipment, $eventId)) { + return $shipment; + } + $this->assertTransition($shipment['status'], $target); + if (!$this->fire('msOnBeforeChangeShipmentStatus', [ + 'shipment' => $shipment, + 'status' => $target, + ])) { + throw new ShipmentLifecycleException('ms3_err_shipment_cancelled'); + } + $fields = ['status' => $target] + $this->transitionTimestamps($shipment, $target); + if ($this->isNonEmpty($eventId)) { + $fields['last_event_id'] = $eventId; + } + $updated = $this->store->update($shipmentId, $fields); + $this->syncOrderStatus($updated['order_id'], $target); + $this->fire('msOnChangeShipmentStatus', ['shipment' => $updated]); + + return $updated; + } + + /** + * @return ShipmentRow + */ + public function applyProviderEvent(ShipmentWebhookEvent $event, int $deliveryId, string $provider): array + { + $shipment = $this->resolveShipment($event, $deliveryId, $provider); + if ($shipment !== null && $this->isReplay($shipment, $event->providerEventId)) { + return $shipment; + } + $from = $shipment['status'] ?? ShipmentStatus::PREPARING; + $this->assertTransition($from, $event->eventType); + if ($shipment === null) { + $shipment = $this->create((int) $event->orderId); + } + + $trackingNumber = $event->trackingNumber !== null ? trim($event->trackingNumber) : ''; + $trackingChanged = $trackingNumber !== '' && $trackingNumber !== (string) $shipment['tracking_number']; + $fields = $this->providerEventFields($shipment, $event); + $fields['status'] = $event->eventType; + $fields += $this->transitionTimestamps($shipment, $event->eventType); + if ($this->isNonEmpty($event->providerEventId)) { + $fields['last_event_id'] = $event->providerEventId; + } + if ($trackingNumber !== '') { + $fields['tracking_number'] = $trackingNumber; + } + + if ($trackingChanged && !$this->fire('msOnBeforeUpdateShipmentTracking', [ + 'shipment' => $shipment, + 'tracking_number' => $trackingNumber, + ])) { + throw new ShipmentLifecycleException('ms3_err_shipment_cancelled'); + } + if (!$this->fire('msOnBeforeChangeShipmentStatus', [ + 'shipment' => $shipment, + 'status' => $event->eventType, + ])) { + throw new ShipmentLifecycleException('ms3_err_shipment_cancelled'); + } + + $updated = $this->store->update($shipment['id'], $fields); + $this->syncOrderStatus($updated['order_id'], $event->eventType); + if ($trackingChanged) { + $this->fire('msOnUpdateShipmentTracking', ['shipment' => $updated]); + } + $this->fire('msOnChangeShipmentStatus', ['shipment' => $updated]); + + return $updated; + } + + /** + * @return ShipmentRow + */ + private function resolveShipment(ShipmentWebhookEvent $event, int $deliveryId, string $provider): ?array + { + if ($this->isNonEmpty($event->externalId)) { + $byExternal = $this->store->findByExternalId($provider, $event->externalId, $deliveryId); + if ($byExternal !== null) { + return $byExternal; + } + } + if ($event->orderId === null || $event->orderId <= 0) { + throw new ShipmentLifecycleException( + 'ms3_err_shipment_nf', + [], + ShipmentLifecycleException::KIND_NOT_FOUND + ); + } + $existing = $this->store->findByOrderId($event->orderId); + if ($existing !== null) { + if ($existing['delivery_id'] !== $deliveryId) { + throw new ShipmentLifecycleException( + 'ms3_err_shipment_event_conflict', + ['from' => 'delivery_id', 'to' => (string) $deliveryId], + ShipmentLifecycleException::KIND_CONFLICT + ); + } + + return $existing; + } + $this->assertOrderBoundToDelivery($event->orderId, $deliveryId); + + return null; + } + + private function assertOrderBoundToDelivery(int $orderId, int $deliveryId): void + { + $order = $this->modx->getObject(msOrder::class, ['id' => $orderId]); + if (!$order instanceof msOrder) { + throw new ShipmentLifecycleException( + 'ms3_err_order_nf', + ['order_id' => $orderId], + ShipmentLifecycleException::KIND_NOT_FOUND + ); + } + if ((int) $order->get('delivery_id') !== $deliveryId) { + throw new ShipmentLifecycleException( + 'ms3_err_shipment_event_conflict', + ['from' => 'delivery_id', 'to' => (string) $deliveryId], + ShipmentLifecycleException::KIND_CONFLICT + ); + } + } + + /** + * @return ShipmentRow + */ + private function requireShipment(int $id): array + { + $row = $this->store->findById($id); + if ($row === null) { + throw new ShipmentLifecycleException( + 'ms3_err_shipment_nf', + ['id' => $id], + ShipmentLifecycleException::KIND_NOT_FOUND + ); + } + + return $row; + } + + private function assertTransition(string $from, string $target): void + { + $allowed = self::ALLOWED_TRANSITIONS[$from] ?? []; + if (!in_array($target, $allowed, true)) { + throw new ShipmentLifecycleException( + 'ms3_err_shipment_event_conflict', + ['from' => $from, 'to' => $target], + ShipmentLifecycleException::KIND_CONFLICT + ); + } + } + + private function syncOrderStatus(int $orderId, string $shipmentStatus): void + { + if (!self::isEnabled($this->modx)) { + return; + } + $statusId = $this->orderStatusFor($shipmentStatus); + if ($statusId <= 0) { + return; + } + $order = $this->modx->getObject(msOrder::class, ['id' => $orderId]); + if (!$order instanceof msOrder) { + return; + } + if ((int) $order->get('status_id') === $statusId) { + return; + } + $result = $this->orderStatus->change($orderId, $statusId); + if ($result !== true) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + sprintf( + 'ShipmentLifecycleService: order #%d status sync to %d failed: %s', + $orderId, + $statusId, + is_string($result) ? $result : 'unknown' + ) + ); + } + } + + private function orderStatusFor(string $shipmentStatus): int + { + return match ($shipmentStatus) { + ShipmentStatus::SHIPPED => (int) $this->modx->getOption('ms3_status_sent', null, 4) ?: 4, + ShipmentStatus::CANCELLED, + ShipmentStatus::FAILED => (int) $this->modx->getOption('ms3_status_canceled', null, 5) ?: 5, + default => (int) $this->modx->getOption('ms3_shipment_on_' . $shipmentStatus . '_status', null, 0), + }; + } + + private function providerFromOrder(msOrder $order): ?string + { + $delivery = $order->getOne('Delivery'); + if ($delivery === null) { + return null; + } + $class = $delivery->get('class'); + + return is_string($class) && $class !== '' ? $class : null; + } + + /** + * @param array $meta + * @return array + */ + private function sanitizeMeta(array $meta): array + { + $clean = []; + foreach ($meta as $key => $value) { + $name = strtolower((string) $key); + if (in_array($name, self::BLOCKED_META_KEYS, true)) { + continue; + } + if (is_array($value)) { + $clean[(string) $key] = $this->sanitizeMeta($value); + continue; + } + if (is_scalar($value) || $value === null) { + $clean[(string) $key] = $value; + } + } + + return $clean; + } + + /** + * @param ShipmentRow $shipment + * @return array + */ + private function transitionTimestamps(array $shipment, string $target): array + { + $now = time(); + $fields = []; + if ($target === ShipmentStatus::SHIPPED || $target === ShipmentStatus::IN_TRANSIT) { + $fields['shipped_at'] = $shipment['shipped_at'] ?? $now; + } + if ($target === ShipmentStatus::DELIVERED) { + $fields['delivered_at'] = $shipment['delivered_at'] ?? $now; + $fields['shipped_at'] = $shipment['shipped_at'] ?? $now; + } + + return $fields; + } + + /** + * @param ShipmentRow $shipment + * @return array + */ + private function providerEventFields(array $shipment, ShipmentWebhookEvent $event): array + { + $fields = []; + if ($this->isNonEmpty($event->externalId)) { + $fields['external_id'] = $event->externalId; + } + if ($this->isNonEmpty($event->carrier)) { + $fields['carrier'] = $event->carrier; + } + if ($event->payload !== []) { + $fields['meta'] = array_merge($shipment['meta'], $this->sanitizeMeta($event->payload)); + } + + return $fields; + } + + /** + * @param ShipmentRow $shipment + */ + private function isReplay(array $shipment, ?string $eventId): bool + { + return $this->isNonEmpty($eventId) && $shipment['last_event_id'] === $eventId; + } + + private function isNonEmpty(?string $value): bool + { + return $value !== null && $value !== ''; + } + + /** + * @param array $params + */ + private function fire(string $event, array $params): bool + { + if (!$this->modx->services->has('ms3')) { + return true; + } + $ms3 = $this->modx->services->get('ms3'); + if (!is_object($ms3) || !isset($ms3->utils)) { + return true; + } + EventGate::clearReturnedValues($this->modx); + $response = $ms3->utils->invokeEvent($event, $params); + + return !empty($response['success']); + } +} diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentPublicDto.php b/core/components/minishop3/src/Services/Shipment/ShipmentPublicDto.php new file mode 100644 index 000000000..329899d61 --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/ShipmentPublicDto.php @@ -0,0 +1,31 @@ + + */ + public static function fromRow(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'order_id' => (int) $row['order_id'], + 'delivery_id' => (int) $row['delivery_id'], + 'status' => (string) $row['status'], + 'tracking_number' => $row['tracking_number'], + 'carrier' => $row['carrier'] ?? null, + 'shipped_at' => $row['shipped_at'], + 'delivered_at' => $row['delivered_at'], + ]; + } +} diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentStatus.php b/core/components/minishop3/src/Services/Shipment/ShipmentStatus.php new file mode 100644 index 000000000..3f8bbcf23 --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/ShipmentStatus.php @@ -0,0 +1,32 @@ + + */ + public static function all(): array + { + return [ + self::PREPARING, + self::SHIPPED, + self::IN_TRANSIT, + self::DELIVERED, + self::CANCELLED, + self::RETURNED, + self::FAILED, + ]; + } +} diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php b/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php new file mode 100644 index 000000000..6c19a86ed --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php @@ -0,0 +1,61 @@ +, + * createdon: int, + * updatedon: int + * } + */ +interface ShipmentStoreInterface +{ + /** + * @param array $meta + * @return ShipmentRow + */ + public function create( + int $orderId, + int $deliveryId, + string $status, + ?string $provider, + array $meta = [], + ): array; + + /** + * @param array $fields + * @return ShipmentRow + */ + public function update(int $id, array $fields): array; + + /** + * @return ShipmentRow|null + */ + public function findById(int $id): ?array; + + /** + * @return ShipmentRow|null + */ + public function findByOrderId(int $orderId): ?array; + + /** + * @return ShipmentRow|null + */ + public function findByExternalId(string $provider, string $externalId, ?int $deliveryId = null): ?array; +} diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentWebhookHmac.php b/core/components/minishop3/src/Services/Shipment/ShipmentWebhookHmac.php new file mode 100644 index 000000000..9c270ce14 --- /dev/null +++ b/core/components/minishop3/src/Services/Shipment/ShipmentWebhookHmac.php @@ -0,0 +1,40 @@ +get('properties'); + if (!is_array($properties)) { + return ''; + } + foreach (['secret', 'secret_key', 'webhook_secret'] as $key) { + $value = $properties[$key] ?? null; + if (is_string($value) && $value !== '') { + return $value; + } + } + + return ''; + } +} diff --git a/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php b/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php index 23e0e9e58..0b4f34196 100644 --- a/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php +++ b/core/components/minishop3/tests/DeliveryPaymentCatalogRoutesTest.php @@ -35,6 +35,8 @@ "group('/payment'", 'DeliveryController', 'PaymentController', + 'DeliveryWebhookController', + "post('/webhook/{delivery_id}'", ] as $needle ) { if (!str_contains($webRoutes, $needle)) { diff --git a/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php b/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php new file mode 100644 index 000000000..404e09c0c --- /dev/null +++ b/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php @@ -0,0 +1,110 @@ +set('status_id'")) { + $fail('ShipmentLifecycleService must not write order status_id'); +} +if (!str_contains($lifecycle, 'OrderStatusService')) { + $fail('ShipmentLifecycleService must use OrderStatusService'); +} + +$submit = $read('src/Services/Order/OrderSubmitHandler.php'); +if (str_contains($submit, 'ms3_shipment_lifecycle') || str_contains($submit, 'ShipmentLifecycle')) { + $fail('OrderSubmitHandler must not create shipments'); +} + +$provider = $read('src/Controllers/Delivery/DeliveryProviderInterface.php'); +if (!str_contains($provider, 'function getCost(')) { + $fail('DeliveryProviderInterface must keep getCost'); +} +if (str_contains($provider, 'createShipment') || str_contains($provider, 'verifyWebhook')) { + $fail('DeliveryProviderInterface must stay cost-only'); +} + +$default = $read('src/Controllers/Delivery/DefaultDelivery.php'); +if (str_contains($default, 'ShipmentProviderInterface')) { + $fail('DefaultDelivery must stay cost-only'); +} + +$dto = $read('src/Services/Shipment/ShipmentPublicDto.php'); +foreach (['meta', 'provider', 'external_id', 'properties'] as $secret) { + if (str_contains($dto, "'{$secret}'")) { + $fail("ShipmentPublicDto must not expose {$secret}"); + } +} + +$cabinet = $read('src/Services/Customer/CustomerOrderService.php'); +if (!str_contains($cabinet, "'shipments'")) { + $fail('CustomerOrderService must expose shipments[]'); +} + +$settings = $read('../../../_build/elements/settings.php'); +if (!str_contains($settings, "'ms3_shipment_enabled'")) { + $fail('settings.php missing ms3_shipment_enabled'); +} +if (!str_contains($settings, "'ms3_status_sent'")) { + $fail('settings.php missing ms3_status_sent'); +} + +$migration = $read('migrations/20260819150000_create_shipments.php'); +if (!str_contains($migration, 'uniq_shipment_order')) { + $fail('migration must unique-index order_id'); +} + +fwrite(STDOUT, "OK ShipmentLifecycleWiringTest\n"); +exit(0); diff --git a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php index cd04e6389..2f282e749 100644 --- a/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php +++ b/core/components/minishop3/tests/TokenMiddlewarePublicRoutesTest.php @@ -45,6 +45,7 @@ '/api/v1/category/tree', '/api/v1/delivery/get/', '/api/v1/delivery/list', + '/api/v1/delivery/webhook/', '/api/v1/payment/get/', '/api/v1/payment/list', '/api/v1/customer/token/get', diff --git a/core/components/minishop3/tests/Unit/Controllers/Api/Web/DeliveryWebhookControllerTest.php b/core/components/minishop3/tests/Unit/Controllers/Api/Web/DeliveryWebhookControllerTest.php new file mode 100644 index 000000000..fae0ce451 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Controllers/Api/Web/DeliveryWebhookControllerTest.php @@ -0,0 +1,336 @@ + */ + private array $statusChanges = []; + + protected function setUp(): void + { + if (!class_exists(modX::class, false)) { + require_once dirname(__DIR__, 4) . '/stubs/ModxStub.php'; + } + require_once dirname(__DIR__, 4) . '/stubs/StubMsDelivery.php'; + require_once dirname(__DIR__, 4) . '/stubs/StubMsOrder.php'; + require_once dirname(__DIR__, 4) . '/support/InMemoryShipmentStore.php'; + $this->statusChanges = []; + } + + public function testDisabledReturnsNotFound(): void + { + $controller = new DeliveryWebhookController(new modX()); + $response = $controller->handle(['delivery_id' => 7]); + $data = $response->getData(); + + self::assertSame(HttpStatus::NOT_FOUND, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::NOT_FOUND, $data['error_code'] ?? null); + } + + public function testMissingDeliveryIdIsBadRequest(): void + { + $controller = new DeliveryWebhookController($this->enabledModx()); + $response = $controller->handle([]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testInvalidJsonIsBadRequest(): void + { + $controller = $this->controllerWithBody( + $this->modxWithHandler($this->handler(true, null)), + '{not-json' + ); + $response = $controller->handle(['delivery_id' => 7]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testUnsupportedHandlerIsBadRequest(): void + { + $costOnly = $this->createMock(DeliveryProviderInterface::class); + $controller = $this->controllerWithBody( + $this->modxWithHandler($costOnly), + '{"event":"shipped"}' + ); + $response = $controller->handle(['delivery_id' => 7]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testBadSignatureIsUnauthorized(): void + { + $controller = $this->controllerWithBody( + $this->modxWithHandler($this->handler(false, null)), + '{"event":"shipped"}' + ); + $response = $controller->handle(['delivery_id' => 7]); + $data = $response->getData(); + + self::assertSame(HttpStatus::UNAUTHORIZED, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::UNAUTHORIZED, $data['error_code'] ?? null); + } + + public function testInvalidPayloadIsBadRequest(): void + { + $controller = $this->controllerWithBody( + $this->modxWithHandler($this->handler(true, null)), + '{"event":"unknown"}' + ); + $response = $controller->handle(['delivery_id' => 7]); + $data = $response->getData(); + + self::assertSame(HttpStatus::BAD_REQUEST, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testVerifyWebhookReceivesRawBody(): void + { + $seenRaw = new stdClass(); + $seenRaw->value = null; + $body = '{"event":"shipped","order_id":10}'; + $handler = $this->handler(false, null, $seenRaw); + $controller = $this->controllerWithBody($this->modxWithHandler($handler), $body); + $controller->handle(['delivery_id' => 7]); + + self::assertSame($body, $seenRaw->value); + } + + public function testShippedWebhookReturnsSuccessOnce(): void + { + $seenRaw = new stdClass(); + $seenRaw->value = null; + $event = new ShipmentWebhookEvent( + eventType: ShipmentStatus::SHIPPED, + orderId: 10, + trackingNumber: 'TRACK-9', + providerEventId: 'hook-1', + ); + $handler = $this->handler(true, $event, $seenRaw); + $store = new InMemoryShipmentStore(); + $order = new StubMsOrder(['id' => 10, 'delivery_id' => 7, 'status_id' => 3]); + $lifecycle = $this->lifecycle($store, $order); + $body = '{"event":"shipped","order_id":10}'; + $controller = $this->controllerWithBody( + $this->modxWithHandler($handler, $lifecycle, $order), + $body + ); + $first = $controller->handle(['delivery_id' => 7]); + $second = $controller->handle(['delivery_id' => 7]); + $data = $first->getData(); + + self::assertSame(HttpStatus::OK, $first->getStatusCode()); + self::assertSame(HttpStatus::OK, $second->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ShipmentStatus::SHIPPED, $data['data']['status'] ?? null); + self::assertSame('TRACK-9', $data['data']['tracking_number'] ?? null); + self::assertSame($body, $seenRaw->value); + self::assertSame([[10, 4]], $this->statusChanges); + } + + public function testUnexpectedLifecycleErrorIsInternal(): void + { + $handler = $this->handler( + true, + new ShipmentWebhookEvent(eventType: ShipmentStatus::SHIPPED, orderId: 10) + ); + $fakeLifecycle = new class { + public function applyProviderEvent(ShipmentWebhookEvent $event, int $deliveryId, string $provider): array + { + throw new \RuntimeException('boom'); + } + }; + $controller = $this->controllerWithBody( + $this->modxWithHandler($handler, $fakeLifecycle), + '{"event":"shipped"}' + ); + $response = $controller->handle(['delivery_id' => 7]); + $data = $response->getData(); + + self::assertSame(HttpStatus::INTERNAL_SERVER_ERROR, $response->getStatusCode()); + self::assertIsArray($data); + self::assertSame(ApiErrorCode::INTERNAL_ERROR, $data['error_code'] ?? null); + } + + public function testDefaultDeliveryDoesNotImplementShipmentContract(): void + { + self::assertFalse( + is_subclass_of(DefaultDelivery::class, ShipmentProviderInterface::class) + ); + } + + private function controllerWithBody(modX $modx, string $body): DeliveryWebhookController + { + return new class ($modx, $body) extends DeliveryWebhookController { + public function __construct(modX $modx, private readonly string $raw) + { + parent::__construct($modx); + } + + protected function readRawRequestBody(): string + { + return $this->raw; + } + }; + } + + private function lifecycle(InMemoryShipmentStore $store, msOrder $order): ShipmentLifecycleService + { + $orderStatus = $this->createMock(OrderStatusService::class); + $orderStatus->method('change')->willReturnCallback( + function (int $orderId, int $statusId): bool { + $this->statusChanges[] = [$orderId, $statusId]; + + return true; + } + ); + + return new ShipmentLifecycleService($store, $this->enabledModx($order), $orderStatus); + } + + private function handler(bool $ok, ?ShipmentWebhookEvent $event, ?stdClass $seenRaw = null): object + { + return new class ($ok, $event, $seenRaw) implements DeliveryProviderInterface, ShipmentProviderInterface { + public function __construct( + private bool $ok, + private ?ShipmentWebhookEvent $event, + private ?stdClass $seenRaw, + ) { + } + + public function getCost(msOrder $order, msDelivery $delivery, float $cost): float + { + return 0.0; + } + + public function verifyWebhook( + string $rawBody, + array $payload, + array $headers, + msDelivery $method + ): bool { + if ($this->seenRaw !== null) { + $this->seenRaw->value = $rawBody; + } + + return $this->ok; + } + + public function parseWebhook(array $payload, array $headers): ?ShipmentWebhookEvent + { + return $this->event; + } + }; + } + + private function modxWithHandler(object $handler, ?object $lifecycle = null, ?msOrder $order = null): modX + { + $delivery = new StubMsDelivery(['id' => 7, 'class' => $handler::class, 'active' => 1]); + $deliveryService = $this->createMock(DeliveryService::class); + $deliveryService->method('loadDeliveryController')->willReturn($handler); + + return $this->enabledModx($order, [ + 'ms3_delivery_service' => $deliveryService, + 'ms3_shipment_lifecycle' => $lifecycle, + ], $delivery); + } + + /** + * @param array $services + */ + private function enabledModx( + ?msOrder $order = null, + array $services = [], + ?msDelivery $delivery = null, + ): modX { + $delivery ??= new StubMsDelivery(['id' => 7, 'class' => 'Fake', 'active' => 1]); + + return new class ($order, $services, $delivery) extends modX { + /** + * @param array $map + */ + public function __construct( + private ?msOrder $order, + private array $map, + private msDelivery $delivery, + ) { + parent::__construct(); + $this->services = new class ($this->map) { + /** @param array $map */ + public function __construct(private array $map) + { + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->map) || $key === 'ms3'; + } + + public function get(string $key): mixed + { + return $this->map[$key] ?? null; + } + }; + } + + public function getOption(string $key, $options = null, $default = null) + { + return match ($key) { + 'ms3_shipment_enabled' => true, + 'ms3_status_sent' => 4, + 'ms3_status_canceled' => 5, + default => $default, + }; + } + + public function getObject($className, $criteria = null) + { + if ($className === msDelivery::class) { + return $this->delivery; + } + if ($className === msOrder::class && $this->order instanceof msOrder) { + if (is_array($criteria) && (int) ($criteria['id'] ?? 0) === (int) $this->order->get('id')) { + return $this->order; + } + } + + return null; + } + }; + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php b/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php new file mode 100644 index 000000000..04c8f26ff --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php @@ -0,0 +1,324 @@ + */ + private array $statusChanges = []; + + protected function setUp(): void + { + if (!class_exists(modX::class, false)) { + require_once dirname(__DIR__, 3) . '/stubs/ModxStub.php'; + } + require_once dirname(__DIR__, 3) . '/stubs/StubMsOrder.php'; + require_once dirname(__DIR__, 3) . '/support/InMemoryShipmentStore.php'; + $this->statusChanges = []; + } + + public function testCreateSnapshotsDeliveryIdAndIsIdempotent(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store); + $first = $service->create(10); + $second = $service->create(10); + + self::assertSame(1, $first['id']); + self::assertSame(7, $first['delivery_id']); + self::assertSame(ShipmentStatus::PREPARING, $first['status']); + self::assertSame($first['id'], $second['id']); + self::assertSame([], $this->statusChanges); + } + + public function testSetTrackingDoesNotChangeOrderStatus(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store); + $row = $service->create(10); + $updated = $service->setTracking($row['id'], 'TRACK-1'); + + self::assertSame('TRACK-1', $updated['tracking_number']); + self::assertSame(ShipmentStatus::PREPARING, $updated['status']); + self::assertSame([], $this->statusChanges); + } + + public function testShippedMapsToSentWhenEnabled(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $row = $service->create(10); + $updated = $service->transition($row['id'], ShipmentStatus::SHIPPED, 'evt-1'); + + self::assertSame(ShipmentStatus::SHIPPED, $updated['status']); + self::assertNotNull($updated['shipped_at']); + self::assertSame([[10, 4]], $this->statusChanges); + + $again = $service->transition($row['id'], ShipmentStatus::SHIPPED, 'evt-1'); + self::assertSame([[10, 4]], $this->statusChanges); + self::assertSame($updated['id'], $again['id']); + } + + public function testDeliveredDoesNotMapOrderStatusByDefault(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $row = $service->create(10); + $service->transition($row['id'], ShipmentStatus::SHIPPED, 'evt-s'); + $this->statusChanges = []; + $delivered = $service->transition($row['id'], ShipmentStatus::DELIVERED, 'evt-d'); + + self::assertSame(ShipmentStatus::DELIVERED, $delivered['status']); + self::assertSame([], $this->statusChanges); + } + + public function testIllegalTransitionConflicts(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store); + $row = $service->create(10); + $this->expectException(ShipmentLifecycleException::class); + $service->transition($row['id'], ShipmentStatus::DELIVERED); + } + + public function testCancelledMapsToCanceledWhenEnabled(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $row = $service->create(10); + $updated = $service->transition($row['id'], ShipmentStatus::CANCELLED, 'evt-c'); + + self::assertSame(ShipmentStatus::CANCELLED, $updated['status']); + self::assertSame([[10, 5]], $this->statusChanges); + } + + public function testFailedMapsToCanceledWhenEnabled(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $row = $service->create(10); + $updated = $service->transition($row['id'], ShipmentStatus::FAILED, 'evt-f'); + + self::assertSame(ShipmentStatus::FAILED, $updated['status']); + self::assertSame([[10, 5]], $this->statusChanges); + } + + public function testOrderStatusFailureDoesNotRollBackShipment(): void + { + $store = new InMemoryShipmentStore(); + $order = new StubMsOrder(['id' => 10, 'delivery_id' => 7, 'status_id' => 3]); + $modx = $this->modx($order, true); + $orderStatus = $this->createMock(OrderStatusService::class); + $orderStatus->method('change')->willReturn('ms3_err_status_final'); + $service = new ShipmentLifecycleService($store, $modx, $orderStatus); + $row = $service->create(10); + $updated = $service->transition($row['id'], ShipmentStatus::SHIPPED, 'evt-1'); + + self::assertSame(ShipmentStatus::SHIPPED, $updated['status']); + self::assertNotNull($updated['shipped_at']); + } + + public function testInTransitDoesNotMapOrderStatusByDefault(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $row = $service->create(10); + $service->transition($row['id'], ShipmentStatus::SHIPPED, 'evt-s'); + $this->statusChanges = []; + $inTransit = $service->transition($row['id'], ShipmentStatus::IN_TRANSIT, 'evt-t'); + + self::assertSame(ShipmentStatus::IN_TRANSIT, $inTransit['status']); + self::assertSame([], $this->statusChanges); + } + + public function testDisabledSkipOrderStatusSync(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: false); + $row = $service->create(10); + $service->transition($row['id'], ShipmentStatus::SHIPPED, 'evt-1'); + + self::assertSame([], $this->statusChanges); + } + + public function testPublicDtoOmitsSecrets(): void + { + $dto = ShipmentPublicDto::fromRow([ + 'id' => 1, + 'order_id' => 10, + 'delivery_id' => 7, + 'status' => ShipmentStatus::SHIPPED, + 'tracking_number' => 'T-1', + 'external_id' => 'ext', + 'provider' => 'Secret\\Class', + 'carrier' => 'Boxberry', + 'shipped_at' => 1, + 'delivered_at' => null, + 'last_event_id' => 'evt', + 'meta' => ['secret' => 'x', 'properties' => ['k' => 'v']], + 'createdon' => 1, + 'updatedon' => 1, + ]); + self::assertSame('T-1', $dto['tracking_number']); + self::assertArrayNotHasKey('meta', $dto); + self::assertArrayNotHasKey('provider', $dto); + self::assertArrayNotHasKey('external_id', $dto); + } + + public function testApplyProviderEventIsIdempotentAndStripsMetaSecrets(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $service->create(10); + $event = new ShipmentWebhookEvent( + eventType: ShipmentStatus::SHIPPED, + orderId: 10, + externalId: 'cdek-1', + trackingNumber: 'TRACK-9', + providerEventId: 'hook-1', + payload: ['secret' => 'nope', 'city' => 'MSK'], + ); + $first = $service->applyProviderEvent($event, 7, 'Cdek'); + $second = $service->applyProviderEvent($event, 7, 'Cdek'); + + self::assertSame('TRACK-9', $first['tracking_number']); + self::assertSame('cdek-1', $first['external_id']); + self::assertArrayNotHasKey('secret', $first['meta']); + self::assertSame('MSK', $first['meta']['city']); + self::assertSame([[10, 4]], $this->statusChanges); + self::assertSame($first['id'], $second['id']); + self::assertSame([[10, 4]], $this->statusChanges); + } + + public function testIllegalProviderEventDoesNotPersistTracking(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $service->create(10); + try { + $service->applyProviderEvent(new ShipmentWebhookEvent( + eventType: ShipmentStatus::DELIVERED, + orderId: 10, + trackingNumber: 'TRACK-X', + providerEventId: 'hook-bad', + payload: ['city' => 'MSK'], + ), 7, 'Cdek'); + self::fail('expected conflict'); + } catch (ShipmentLifecycleException $exception) { + self::assertSame(ShipmentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + $row = $store->findByOrderId(10); + self::assertNotNull($row); + self::assertSame(ShipmentStatus::PREPARING, $row['status']); + self::assertNull($row['tracking_number']); + self::assertSame([], $row['meta']); + self::assertSame([], $this->statusChanges); + } + + public function testIllegalFirstWebhookDoesNotCreateShipment(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + try { + $service->applyProviderEvent(new ShipmentWebhookEvent( + eventType: ShipmentStatus::DELIVERED, + orderId: 10, + trackingNumber: 'TRACK-X', + ), 7, 'Cdek'); + self::fail('expected conflict'); + } catch (ShipmentLifecycleException $exception) { + self::assertSame(ShipmentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + self::assertNull($store->findByOrderId(10)); + self::assertSame([], $this->statusChanges); + } + + public function testWebhookDeliveryMismatchDoesNotCreateShipment(): void + { + $store = new InMemoryShipmentStore(); + $order = new StubMsOrder(['id' => 10, 'delivery_id' => 5, 'status_id' => 3]); + $orderStatus = $this->createMock(OrderStatusService::class); + $orderStatus->method('change')->willReturnCallback( + function (int $orderId, int $statusId): bool { + $this->statusChanges[] = [$orderId, $statusId]; + + return true; + } + ); + $service = new ShipmentLifecycleService($store, $this->modx($order, true), $orderStatus); + try { + $service->applyProviderEvent(new ShipmentWebhookEvent( + eventType: ShipmentStatus::SHIPPED, + orderId: 10, + trackingNumber: 'TRACK-X', + providerEventId: 'hook-x', + ), 7, 'Cdek'); + self::fail('expected conflict'); + } catch (ShipmentLifecycleException $exception) { + self::assertSame(ShipmentLifecycleException::KIND_CONFLICT, $exception->getKind()); + } + self::assertNull($store->findByOrderId(10)); + self::assertSame([], $this->statusChanges); + } + + private function service(InMemoryShipmentStore $store, bool $enabled = false): ShipmentLifecycleService + { + $order = new StubMsOrder(['id' => 10, 'delivery_id' => 7, 'status_id' => 3]); + $orderStatus = $this->createMock(OrderStatusService::class); + $orderStatus->method('change')->willReturnCallback( + function (int $orderId, int $statusId): bool { + $this->statusChanges[] = [$orderId, $statusId]; + + return true; + } + ); + + return new ShipmentLifecycleService($store, $this->modx($order, $enabled), $orderStatus); + } + + private function modx(msOrder $order, bool $enabled): modX + { + return new class ($order, $enabled) extends modX { + public function __construct(private msOrder $order, private bool $enabled) + { + parent::__construct(); + } + + public function getOption(string $key, $options = null, $default = null) + { + return match ($key) { + 'ms3_shipment_enabled' => $this->enabled, + 'ms3_status_sent' => 4, + 'ms3_status_canceled' => 5, + default => $default, + }; + } + + public function getObject($className, $criteria = null) + { + if ($className !== msOrder::class) { + return null; + } + if (is_array($criteria) && (int) ($criteria['id'] ?? 0) === (int) $this->order->get('id')) { + return $this->order; + } + + return null; + } + }; + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentWebhookHmacTest.php b/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentWebhookHmacTest.php new file mode 100644 index 000000000..1bec2ee29 --- /dev/null +++ b/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentWebhookHmacTest.php @@ -0,0 +1,42 @@ + ['webhook_secret' => 'from-properties'], + ]); + self::assertSame('from-properties', ShipmentWebhookHmac::secretFrom($method)); + self::assertSame('', ShipmentWebhookHmac::secretFrom(new \MiniShop3\Tests\Stubs\StubMsDelivery())); + } +} diff --git a/core/components/minishop3/tests/bootstrap.php b/core/components/minishop3/tests/bootstrap.php index 41b1bbd9a..31cc28a2b 100644 --- a/core/components/minishop3/tests/bootstrap.php +++ b/core/components/minishop3/tests/bootstrap.php @@ -14,3 +14,4 @@ require __DIR__ . '/support/SqliteDraftCartProduct.php'; require __DIR__ . '/support/SqliteHarnessCart.php'; require __DIR__ . '/support/SqliteDraftCartHarnessTrait.php'; +require __DIR__ . '/support/InMemoryShipmentStore.php'; diff --git a/core/components/minishop3/tests/stubs/StubMsOrder.php b/core/components/minishop3/tests/stubs/StubMsOrder.php index 7c0642c8e..85ec2b1a9 100644 --- a/core/components/minishop3/tests/stubs/StubMsOrder.php +++ b/core/components/minishop3/tests/stubs/StubMsOrder.php @@ -23,4 +23,9 @@ public function get($key) { return $this->fields[$key] ?? null; } + + public function getOne($alias) + { + return $this->fields['_related'][$alias] ?? null; + } } diff --git a/core/components/minishop3/tests/support/InMemoryShipmentStore.php b/core/components/minishop3/tests/support/InMemoryShipmentStore.php new file mode 100644 index 000000000..4b61e93d1 --- /dev/null +++ b/core/components/minishop3/tests/support/InMemoryShipmentStore.php @@ -0,0 +1,101 @@ + */ + private array $rows = []; + + private int $nextId = 1; + + public function create( + int $orderId, + int $deliveryId, + string $status, + ?string $provider, + array $meta = [], + ): array { + $existing = $this->findByOrderId($orderId); + if ($existing !== null) { + return $existing; + } + $now = time(); + $row = [ + 'id' => $this->nextId++, + 'order_id' => $orderId, + 'delivery_id' => $deliveryId, + 'status' => $status, + 'tracking_number' => null, + 'external_id' => null, + 'provider' => $provider, + 'carrier' => null, + 'shipped_at' => null, + 'delivered_at' => null, + 'last_event_id' => null, + 'meta' => $meta, + 'createdon' => $now, + 'updatedon' => $now, + ]; + $this->rows[$row['id']] = $row; + + return $row; + } + + public function update(int $id, array $fields): array + { + $row = $this->rows[$id] ?? null; + if ($row === null) { + throw new \RuntimeException('shipment not found'); + } + foreach ($fields as $key => $value) { + if ($key === 'id' || $key === 'createdon') { + continue; + } + $row[$key] = $value; + } + $row['updatedon'] = time(); + $this->rows[$id] = $row; + + return $row; + } + + public function findById(int $id): ?array + { + return $this->rows[$id] ?? null; + } + + public function findByOrderId(int $orderId): ?array + { + foreach ($this->rows as $row) { + if ($row['order_id'] === $orderId) { + return $row; + } + } + + return null; + } + + public function findByExternalId(string $provider, string $externalId, ?int $deliveryId = null): ?array + { + foreach ($this->rows as $row) { + if ($row['provider'] !== $provider || $row['external_id'] !== $externalId) { + continue; + } + if ($deliveryId !== null && $row['delivery_id'] !== $deliveryId) { + continue; + } + + return $row; + } + + return null; + } +} From b6e1652ef0b86d549d51cd36255628935d70cfb4 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Wed, 19 Aug 2026 11:16:49 +0600 Subject: [PATCH 2/2] feat(core): persist shipment events and add manager tracking Keep webhook replay honest across event ids, expose shipment in Fenom and the order editor, and cover PdoShipmentStore on MySQL. --- .../minishop3/config/routes/manager.php | 8 + .../elements/snippets/ms3_get_order.php | 1 + .../minishop3/lexicon/en/vue.inc.php | 16 ++ .../minishop3/lexicon/ru/vue.inc.php | 16 ++ .../20260819160000_create_shipment_events.php | 34 +++ .../Api/Manager/OrderShipmentController.php | 167 +++++++++++ core/components/minishop3/src/MiniShop3.php | 20 ++ .../minishop3/src/ServiceRegistry.php | 3 +- .../src/ServiceRegistryFactories.php | 6 +- .../Customer/CustomerOrderService.php | 8 +- .../src/Services/Payment/PaymentService.php | 20 +- .../Services/Shipment/PdoShipmentStore.php | 37 +++ .../Shipment/ShipmentLifecycleService.php | 37 ++- .../Shipment/ShipmentStoreInterface.php | 4 + .../Mysql/PdoShipmentStoreMysqlTest.php | 138 +++++++++ .../tests/OrdersRoutePermissionsTest.php | 2 + .../tests/ShipmentLifecycleWiringTest.php | 26 ++ .../Manager/OrderShipmentControllerTest.php | 149 ++++++++++ .../Shipment/ShipmentLifecycleServiceTest.php | 31 +++ .../tests/support/InMemoryShipmentStore.php | 18 ++ vueManager/src/components/OrderView.vue | 2 + .../src/components/order/OrderShipmentTab.vue | 261 ++++++++++++++++++ .../src/composables/useOrderPluginTabs.js | 11 +- vueManager/src/entries/order.js | 6 +- vueManager/src/utils/orderPluginTab.js | 8 +- vueManager/src/utils/orderPluginTab.test.js | 16 ++ 26 files changed, 1018 insertions(+), 27 deletions(-) create mode 100644 core/components/minishop3/migrations/20260819160000_create_shipment_events.php create mode 100644 core/components/minishop3/src/Controllers/Api/Manager/OrderShipmentController.php create mode 100644 core/components/minishop3/tests/Integration/Mysql/PdoShipmentStoreMysqlTest.php create mode 100644 core/components/minishop3/tests/Unit/Controllers/Api/Manager/OrderShipmentControllerTest.php create mode 100644 vueManager/src/components/order/OrderShipmentTab.vue create mode 100644 vueManager/src/utils/orderPluginTab.test.js diff --git a/core/components/minishop3/config/routes/manager.php b/core/components/minishop3/config/routes/manager.php index b86435845..ce0f0e55c 100644 --- a/core/components/minishop3/config/routes/manager.php +++ b/core/components/minishop3/config/routes/manager.php @@ -816,6 +816,9 @@ $router->get('/{id}/logs', function ($params) use ($modx) { return (new \MiniShop3\Controllers\Api\Manager\OrdersController($modx))->getLogs($params); }); + $router->get('/{id}/shipment', function ($params) use ($modx) { + return (new \MiniShop3\Controllers\Api\Manager\OrderShipmentController($modx))->get($params); + }); }, [ new PermissionMiddleware($modx, 'msorder_list') ]); @@ -864,6 +867,11 @@ $router->delete('/{id}/products/{product_id}', function ($params) use ($modx) { return (new \MiniShop3\Controllers\Api\Manager\OrdersController($modx))->deleteProduct($params); }); + $router->put('/{id}/shipment', function ($params) use ($modx) { + $data = json_decode(file_get_contents('php://input'), true) ?: []; + return (new \MiniShop3\Controllers\Api\Manager\OrderShipmentController($modx)) + ->save(array_merge($params, is_array($data) ? $data : [])); + }); }, [ new PermissionMiddleware($modx, 'msorder_save') ]); diff --git a/core/components/minishop3/elements/snippets/ms3_get_order.php b/core/components/minishop3/elements/snippets/ms3_get_order.php index 53abf4882..0f9e550ea 100644 --- a/core/components/minishop3/elements/snippets/ms3_get_order.php +++ b/core/components/minishop3/elements/snippets/ms3_get_order.php @@ -263,6 +263,7 @@ 'payment' => ($payment = $msOrder->getOne('Payment')) ? $payment->toArray() : [], + 'shipments' => $ms3->shipmentPublicForOrder((int) $id), 'total' => [ 'cost' => (float)$msOrder->get('cost'), 'cost_formatted' => $ms3->format->price($msOrder->get('cost'), true), diff --git a/core/components/minishop3/lexicon/en/vue.inc.php b/core/components/minishop3/lexicon/en/vue.inc.php index 3e59cca7d..753edb8f1 100644 --- a/core/components/minishop3/lexicon/en/vue.inc.php +++ b/core/components/minishop3/lexicon/en/vue.inc.php @@ -613,7 +613,23 @@ $_lang['order_dates'] = 'Dates'; $_lang['order_products'] = 'Products'; $_lang['order_address'] = 'Address'; +$_lang['order_tracking'] = 'Tracking'; $_lang['order_history'] = 'History'; +$_lang['shipment_empty'] = 'No shipment yet'; +$_lang['shipment_create'] = 'Create shipment'; +$_lang['shipment_saved'] = 'Shipment saved'; +$_lang['shipment_status'] = 'Shipment status'; +$_lang['shipment_tracking_number'] = 'Tracking number'; +$_lang['shipment_carrier'] = 'Carrier'; +$_lang['shipment_shipped_at'] = 'Shipped'; +$_lang['shipment_delivered_at'] = 'Delivered'; +$_lang['shipment_status_preparing'] = 'Preparing'; +$_lang['shipment_status_shipped'] = 'Shipped'; +$_lang['shipment_status_in_transit'] = 'In transit'; +$_lang['shipment_status_delivered'] = 'Delivered'; +$_lang['shipment_status_cancelled'] = 'Cancelled'; +$_lang['shipment_status_returned'] = 'Returned'; +$_lang['shipment_status_failed'] = 'Failed'; $_lang['order_comment'] = 'Comment'; $_lang['select_status'] = 'Select status'; $_lang['select_delivery'] = 'Select delivery'; diff --git a/core/components/minishop3/lexicon/ru/vue.inc.php b/core/components/minishop3/lexicon/ru/vue.inc.php index 64d600371..dd2674011 100644 --- a/core/components/minishop3/lexicon/ru/vue.inc.php +++ b/core/components/minishop3/lexicon/ru/vue.inc.php @@ -612,7 +612,23 @@ $_lang['order_dates'] = 'Даты'; $_lang['order_products'] = 'Товары'; $_lang['order_address'] = 'Адрес'; +$_lang['order_tracking'] = 'Отслеживание'; $_lang['order_history'] = 'История'; +$_lang['shipment_empty'] = 'Отгрузка ещё не создана'; +$_lang['shipment_create'] = 'Создать отгрузку'; +$_lang['shipment_saved'] = 'Отгрузка сохранена'; +$_lang['shipment_status'] = 'Статус отгрузки'; +$_lang['shipment_tracking_number'] = 'Трек-номер'; +$_lang['shipment_carrier'] = 'Перевозчик'; +$_lang['shipment_shipped_at'] = 'Отправлено'; +$_lang['shipment_delivered_at'] = 'Доставлено'; +$_lang['shipment_status_preparing'] = 'Подготовка'; +$_lang['shipment_status_shipped'] = 'Отправлено'; +$_lang['shipment_status_in_transit'] = 'В пути'; +$_lang['shipment_status_delivered'] = 'Доставлено'; +$_lang['shipment_status_cancelled'] = 'Отменено'; +$_lang['shipment_status_returned'] = 'Возврат'; +$_lang['shipment_status_failed'] = 'Ошибка'; $_lang['order_comment'] = 'Комментарий'; $_lang['select_status'] = 'Выберите статус'; $_lang['select_delivery'] = 'Выберите способ доставки'; diff --git a/core/components/minishop3/migrations/20260819160000_create_shipment_events.php b/core/components/minishop3/migrations/20260819160000_create_shipment_events.php new file mode 100644 index 000000000..c86f50306 --- /dev/null +++ b/core/components/minishop3/migrations/20260819160000_create_shipment_events.php @@ -0,0 +1,34 @@ +hasTable('ms3_shipment_events')) { + return; + } + + $this->table('ms3_shipment_events', [ + 'id' => true, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('shipment_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('provider_event_id', 'string', ['limit' => 191, 'null' => false]) + ->addColumn('createdon', 'integer', ['signed' => false, 'null' => true, 'default' => null]) + ->addIndex(['shipment_id', 'provider_event_id'], [ + 'unique' => true, + 'name' => 'uniq_shipment_provider_event', + ]) + ->create(); + } +} diff --git a/core/components/minishop3/src/Controllers/Api/Manager/OrderShipmentController.php b/core/components/minishop3/src/Controllers/Api/Manager/OrderShipmentController.php new file mode 100644 index 000000000..5c3047fc2 --- /dev/null +++ b/core/components/minishop3/src/Controllers/Api/Manager/OrderShipmentController.php @@ -0,0 +1,167 @@ +modx->lexicon->load('minishop3:default'); + } + + /** + * @param array $params + * @return array + */ + public function get(array $params = []): array + { + $orderId = $this->orderId($params); + if ($orderId === null) { + return $this->fail('ms3_err_order_nf', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + if ($this->loadOrder($orderId) === null) { + return $this->fail('ms3_err_order_nf', HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND); + } + $lifecycle = $this->lifecycle(); + if ($lifecycle === null) { + return $this->fail('ms3_err_unknown', HttpStatus::INTERNAL_SERVER_ERROR, ApiErrorCode::INTERNAL_ERROR); + } + $row = $lifecycle->findByOrderId($orderId); + + return Response::success($this->payload($row))->getData(); + } + + /** + * Create if missing, then optional tracking / status. + * + * @param array $params + * @return array + */ + public function save(array $params = []): array + { + $orderId = $this->orderId($params); + if ($orderId === null) { + return $this->fail('ms3_err_order_nf', HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST); + } + if ($this->loadOrder($orderId) === null) { + return $this->fail('ms3_err_order_nf', HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND); + } + $lifecycle = $this->lifecycle(); + if ($lifecycle === null) { + return $this->fail('ms3_err_unknown', HttpStatus::INTERNAL_SERVER_ERROR, ApiErrorCode::INTERNAL_ERROR); + } + + try { + $row = $lifecycle->findByOrderId($orderId) ?? $lifecycle->create($orderId); + $tracking = trim((string) ($params['tracking_number'] ?? '')); + if ($tracking !== '') { + $row = $lifecycle->setTracking((int) $row['id'], $tracking); + } + $status = trim((string) ($params['status'] ?? '')); + if ($status !== '' && $status !== $row['status']) { + $row = $lifecycle->transition((int) $row['id'], $status); + } + } catch (ShipmentLifecycleException $exception) { + return $this->fromLifecycle($exception); + } + + return Response::success($this->payload($row))->getData(); + } + + /** + * @param array|null $row + * @return array{shipment: array|null, statuses: list} + */ + private function payload(?array $row): array + { + return [ + 'shipment' => $row !== null ? ShipmentPublicDto::fromRow($row) : null, + 'statuses' => ShipmentStatus::all(), + ]; + } + + /** + * @param array $params + */ + private function orderId(array $params): ?int + { + $orderId = (int) ($params['id'] ?? 0); + + return $orderId > 0 ? $orderId : null; + } + + private function loadOrder(int $orderId): ?msOrder + { + $order = $this->modx->getObject(msOrder::class, $orderId); + + return $order instanceof msOrder ? $order : null; + } + + private function lifecycle(): ?ShipmentLifecycleService + { + if (!$this->modx->services->has('ms3_shipment_lifecycle')) { + return null; + } + $lifecycle = $this->modx->services->get('ms3_shipment_lifecycle'); + + return $lifecycle instanceof ShipmentLifecycleService ? $lifecycle : null; + } + + /** + * @return array + */ + private function fromLifecycle(ShipmentLifecycleException $exception): array + { + [$status, $errorCode] = match ($exception->getKind()) { + ShipmentLifecycleException::KIND_CONFLICT => [HttpStatus::CONFLICT, ApiErrorCode::CONFLICT], + ShipmentLifecycleException::KIND_NOT_FOUND => [HttpStatus::NOT_FOUND, ApiErrorCode::NOT_FOUND], + default => [HttpStatus::BAD_REQUEST, ApiErrorCode::BAD_REQUEST], + }; + + return Response::error( + $this->lexicon($exception->getLexiconKey(), $exception->getPlaceholders(), $exception->getMessage()), + $status, + null, + $errorCode + )->getData(); + } + + /** + * @return array + */ + private function fail(string $message, int $status, string $errorCode): array + { + return Response::error($this->lexicon($message), $status, null, $errorCode)->getData(); + } + + /** + * @param array $placeholders + */ + private function lexicon(string $key, array $placeholders = [], ?string $fallback = null): string + { + $message = $this->modx->lexicon($key, $placeholders); + if (is_string($message) && $message !== '') { + return $message; + } + + return $fallback ?? $key; + } +} diff --git a/core/components/minishop3/src/MiniShop3.php b/core/components/minishop3/src/MiniShop3.php index ce8c66d36..04d9ad6ec 100644 --- a/core/components/minishop3/src/MiniShop3.php +++ b/core/components/minishop3/src/MiniShop3.php @@ -9,6 +9,7 @@ use MiniShop3\Controllers\Order\Order; use MiniShop3\Controllers\Payment\PaymentProviderInterface; use MiniShop3\ServiceRegistry; +use MiniShop3\Services\Shipment\ShipmentLifecycleService; use MiniShop3\Utils\ExtraFields; use MiniShop3\Utils\Format; use MiniShop3\Utils\Services; @@ -250,6 +251,25 @@ public function getCustomer(): \MiniShop3\Controllers\Customer\Customer return $this->modx->services->get('ms3_customer'); } + /** + * Public shipment rows for Fenom / storefront. Uses `$this->modx` so snippets + * do not add extra `$modx->services` hits (phpstan baseline ignore.count). + * + * @return list> + */ + public function shipmentPublicForOrder(int $orderId): array + { + if (!$this->modx->services->has('ms3_shipment_lifecycle')) { + return []; + } + $lifecycle = $this->modx->services->get('ms3_shipment_lifecycle'); + if (!$lifecycle instanceof ShipmentLifecycleService) { + return []; + } + + return $lifecycle->publicListForOrder($orderId); + } + /** * Loads extra fields metadata into xPDO map. * Idempotent - safe to call multiple times, loads only once per request. diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 8cac4e0f9..56134c9b7 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -3,6 +3,7 @@ namespace MiniShop3; use MiniShop3\Services\Order\OrderDraftManager; +use MiniShop3\Services\Payment\PaymentService; use MiniShop3\Services\Product\Import\ProductImportService; use MODX\Revolution\modX; @@ -211,7 +212,7 @@ class ServiceRegistry 'interface' => null, ], 'ms3_payment_service' => [ - 'class' => \MiniShop3\Services\Payment\PaymentService::class, + 'class' => PaymentService::class, 'interface' => null, ], 'ms3_payment_link_resolver' => [ diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php index 1d6c10e1b..6f3a2b972 100644 --- a/core/components/minishop3/src/ServiceRegistryFactories.php +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -56,7 +56,11 @@ public static function map(): array throw new \RuntimeException('ms3_shipment_lifecycle requires MODX PDO'); } $prefix = (string) $modx->getOption('table_prefix', null, ''); - $store = new PdoShipmentStore($modx->pdo, $prefix . 'ms3_shipments'); + $store = new PdoShipmentStore( + $modx->pdo, + $prefix . 'ms3_shipments', + $prefix . 'ms3_shipment_events' + ); return new $class($store, $modx, $services->get('ms3_order_status')); }, diff --git a/core/components/minishop3/src/Services/Customer/CustomerOrderService.php b/core/components/minishop3/src/Services/Customer/CustomerOrderService.php index 5485ca8f4..ea5bc13da 100644 --- a/core/components/minishop3/src/Services/Customer/CustomerOrderService.php +++ b/core/components/minishop3/src/Services/Customer/CustomerOrderService.php @@ -8,7 +8,6 @@ use MiniShop3\Model\msOrderStatus; use MiniShop3\Services\Order\OrderStatusService; use MiniShop3\Services\Shipment\ShipmentLifecycleService; -use MiniShop3\Services\Shipment\ShipmentPublicDto; use MODX\Revolution\modX; /** @@ -334,12 +333,7 @@ protected function publicShipments(int $orderId): array if (!$lifecycle instanceof ShipmentLifecycleService) { return []; } - $row = $lifecycle->findByOrderId($orderId); - if ($row === null) { - return []; - } - - return [ShipmentPublicDto::fromRow($row)]; + return $lifecycle->publicListForOrder($orderId); } protected function formatAddress(msOrderAddress $address): array diff --git a/core/components/minishop3/src/Services/Payment/PaymentService.php b/core/components/minishop3/src/Services/Payment/PaymentService.php index fcfc1ad1a..ff612712e 100644 --- a/core/components/minishop3/src/Services/Payment/PaymentService.php +++ b/core/components/minishop3/src/Services/Payment/PaymentService.php @@ -1,5 +1,7 @@ modx = $modx; if ($modx->services->has('ms3')) { - $this->ms3 = $modx->services->get('ms3'); + $ms3 = $modx->services->get('ms3'); + $this->ms3 = $ms3 instanceof MiniShop3 ? $ms3 : null; } } @@ -62,7 +59,8 @@ public function loadPaymentHandler(msPayment $payment): ?PaymentProviderInterfac $this->modx->log( modX::LOG_LEVEL_ERROR, sprintf( - 'PaymentService: Class "%s" does not implement PaymentProviderInterface for payment method ID=%d', + 'PaymentService: Class "%s" does not implement' + . ' PaymentProviderInterface for payment method ID=%d', $class, $payment->get('id') ) diff --git a/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php b/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php index d7e367c07..4ffc0beb1 100644 --- a/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php +++ b/core/components/minishop3/src/Services/Shipment/PdoShipmentStore.php @@ -17,11 +17,15 @@ final class PdoShipmentStore implements ShipmentStoreInterface { private string $table; + private string $eventsTable; + public function __construct( private readonly PDO $db, string $table, + string $eventsTable, ) { $this->table = $this->quoteTable($table); + $this->eventsTable = $this->quoteTable($eventsTable); } public function create( @@ -120,6 +124,39 @@ public function findByExternalId(string $provider, string $externalId, ?int $del return $this->fetchOne($sql, $params); } + public function hasEvent(int $shipmentId, string $providerEventId): bool + { + $sql = "SELECT 1 FROM {$this->eventsTable} + WHERE shipment_id = :shipment_id AND provider_event_id = :provider_event_id + LIMIT 1"; + $stmt = $this->prepare($sql); + $stmt->execute([ + 'shipment_id' => $shipmentId, + 'provider_event_id' => $providerEventId, + ]); + + return $stmt->fetchColumn() !== false; + } + + public function recordEvent(int $shipmentId, string $providerEventId): void + { + $sql = "INSERT INTO {$this->eventsTable} + (shipment_id, provider_event_id, createdon) + VALUES (:shipment_id, :provider_event_id, :createdon)"; + try { + $this->prepare($sql)->execute([ + 'shipment_id' => $shipmentId, + 'provider_event_id' => $providerEventId, + 'createdon' => time(), + ]); + } catch (PDOException $exception) { + if ($this->isDuplicate($exception)) { + return; + } + throw $exception; + } + } + /** * @param array $params * @return ShipmentRow|null diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php b/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php index fe05bf48e..06ddd74e1 100644 --- a/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php +++ b/core/components/minishop3/src/Services/Shipment/ShipmentLifecycleService.php @@ -117,6 +117,19 @@ public function findByOrderId(int $orderId): ?array return $this->store->findByOrderId($orderId); } + /** + * @return list> + */ + public function publicListForOrder(int $orderId): array + { + $row = $this->findByOrderId($orderId); + if ($row === null) { + return []; + } + + return [ShipmentPublicDto::fromRow($row)]; + } + /** * @return ShipmentRow */ @@ -141,6 +154,7 @@ public function setTracking(int $shipmentId, string $trackingNumber, ?string $ev $fields['last_event_id'] = $eventId; } $updated = $this->store->update($shipmentId, $fields); + $this->rememberEvent($shipmentId, $eventId); $this->fire('msOnUpdateShipmentTracking', ['shipment' => $updated]); return $updated; @@ -167,6 +181,7 @@ public function transition(int $shipmentId, string $target, ?string $eventId = n $fields['last_event_id'] = $eventId; } $updated = $this->store->update($shipmentId, $fields); + $this->rememberEvent($shipmentId, $eventId); $this->syncOrderStatus($updated['order_id'], $target); $this->fire('msOnChangeShipmentStatus', ['shipment' => $updated]); @@ -214,6 +229,7 @@ public function applyProviderEvent(ShipmentWebhookEvent $event, int $deliveryId, } $updated = $this->store->update($shipment['id'], $fields); + $this->rememberEvent((int) $updated['id'], $event->providerEventId); $this->syncOrderStatus($updated['order_id'], $event->eventType); if ($trackingChanged) { $this->fire('msOnUpdateShipmentTracking', ['shipment' => $updated]); @@ -425,7 +441,26 @@ private function providerEventFields(array $shipment, ShipmentWebhookEvent $even */ private function isReplay(array $shipment, ?string $eventId): bool { - return $this->isNonEmpty($eventId) && $shipment['last_event_id'] === $eventId; + if (!$this->isNonEmpty($eventId)) { + return false; + } + if ($this->store->hasEvent((int) $shipment['id'], $eventId)) { + return true; + } + if ($shipment['last_event_id'] === $eventId) { + $this->store->recordEvent((int) $shipment['id'], $eventId); + + return true; + } + + return false; + } + + private function rememberEvent(int $shipmentId, ?string $eventId): void + { + if ($this->isNonEmpty($eventId)) { + $this->store->recordEvent($shipmentId, $eventId); + } } private function isNonEmpty(?string $value): bool diff --git a/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php b/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php index 6c19a86ed..d4c39c355 100644 --- a/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php +++ b/core/components/minishop3/src/Services/Shipment/ShipmentStoreInterface.php @@ -58,4 +58,8 @@ public function findByOrderId(int $orderId): ?array; * @return ShipmentRow|null */ public function findByExternalId(string $provider, string $externalId, ?int $deliveryId = null): ?array; + + public function hasEvent(int $shipmentId, string $providerEventId): bool; + + public function recordEvent(int $shipmentId, string $providerEventId): void; } diff --git a/core/components/minishop3/tests/Integration/Mysql/PdoShipmentStoreMysqlTest.php b/core/components/minishop3/tests/Integration/Mysql/PdoShipmentStoreMysqlTest.php new file mode 100644 index 000000000..edc82383a --- /dev/null +++ b/core/components/minishop3/tests/Integration/Mysql/PdoShipmentStoreMysqlTest.php @@ -0,0 +1,138 @@ +pdo = MysqlTestConnection::requireOrSkip($this); + $suffix = bin2hex(random_bytes(4)); + $this->shipmentsTable = 'ms3_test_shipments_' . $suffix; + $this->eventsTable = 'ms3_test_shipment_events_' . $suffix; + $this->createTables(); + $this->store = new PdoShipmentStore($this->pdo, $this->shipmentsTable, $this->eventsTable); + } + + protected function tearDown(): void + { + if (!isset($this->pdo)) { + return; + } + $this->dropTable($this->eventsTable); + $this->dropTable($this->shipmentsTable); + } + + public function testCreateFindAndDuplicateOrderId(): void + { + $first = $this->store->create(10, 7, 'preparing', 'Cdek', ['city' => 'MSK']); + $again = $this->store->create(10, 8, 'shipped', 'Other'); + $byOrder = $this->store->findByOrderId(10); + $byId = $this->store->findById($first['id']); + + self::assertSame($first['id'], $again['id']); + self::assertSame(7, $again['delivery_id']); + self::assertSame($first['id'], $byOrder['id'] ?? null); + self::assertSame('MSK', $byId['meta']['city'] ?? null); + self::assertSame('preparing', $byId['status'] ?? null); + } + + public function testUpdateAndExternalIdLookup(): void + { + $row = $this->store->create(11, 7, 'preparing', 'Cdek'); + $updated = $this->store->update($row['id'], [ + 'status' => 'shipped', + 'tracking_number' => 'TRACK-1', + 'external_id' => 'cdek-11', + 'carrier' => 'CDEK', + ]); + $byExternal = $this->store->findByExternalId('Cdek', 'cdek-11', 7); + + self::assertSame('shipped', $updated['status']); + self::assertSame('TRACK-1', $updated['tracking_number']); + self::assertSame($row['id'], $byExternal['id'] ?? null); + self::assertNull($this->store->findByExternalId('Cdek', 'cdek-11', 8)); + } + + public function testEventHistoryIsUniquePerShipment(): void + { + $row = $this->store->create(12, 7, 'preparing', 'Cdek'); + self::assertFalse($this->store->hasEvent($row['id'], 'evt-a')); + $this->store->recordEvent($row['id'], 'evt-a'); + $this->store->recordEvent($row['id'], 'evt-a'); + $this->store->recordEvent($row['id'], 'evt-b'); + + self::assertTrue($this->store->hasEvent($row['id'], 'evt-a')); + self::assertTrue($this->store->hasEvent($row['id'], 'evt-b')); + self::assertFalse($this->store->hasEvent($row['id'], 'evt-c')); + + $count = (int) $this->pdo->query( + 'SELECT COUNT(*) FROM `' . $this->eventsTable . '` WHERE shipment_id = ' . (int) $row['id'] + )->fetchColumn(); + self::assertSame(2, $count); + } + + private function dropTable(string $table): void + { + $this->pdo->query('DROP TABLE IF EXISTS `' . $table . '`'); + } + + private function createTables(): void + { + $shipments = $this->shipmentsTable; + $events = $this->eventsTable; + $this->pdo->query( + "CREATE TABLE `{$shipments}` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `order_id` INT UNSIGNED NOT NULL, + `delivery_id` INT UNSIGNED NOT NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'preparing', + `tracking_number` VARCHAR(191) NULL DEFAULT NULL, + `external_id` VARCHAR(191) NULL DEFAULT NULL, + `provider` VARCHAR(191) NULL DEFAULT NULL, + `carrier` VARCHAR(191) NULL DEFAULT NULL, + `shipped_at` INT UNSIGNED NULL DEFAULT NULL, + `delivered_at` INT UNSIGNED NULL DEFAULT NULL, + `last_event_id` VARCHAR(191) NULL DEFAULT NULL, + `meta` TEXT NULL, + `createdon` INT UNSIGNED NULL DEFAULT NULL, + `updatedon` INT UNSIGNED NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_shipment_order` (`order_id`), + UNIQUE KEY `uniq_shipment_external` (`delivery_id`, `provider`, `external_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + $this->pdo->query( + "CREATE TABLE `{$events}` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `shipment_id` INT UNSIGNED NOT NULL, + `provider_event_id` VARCHAR(191) NOT NULL, + `createdon` INT UNSIGNED NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_shipment_provider_event` (`shipment_id`, `provider_event_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } +} diff --git a/core/components/minishop3/tests/OrdersRoutePermissionsTest.php b/core/components/minishop3/tests/OrdersRoutePermissionsTest.php index 70475dc0f..4a40b3ff2 100644 --- a/core/components/minishop3/tests/OrdersRoutePermissionsTest.php +++ b/core/components/minishop3/tests/OrdersRoutePermissionsTest.php @@ -92,6 +92,7 @@ 'GET /api/mgr/orders/{id}' => 'msorder_list', 'GET /api/mgr/orders/{id}/products' => 'msorder_list', 'GET /api/mgr/orders/{id}/logs' => 'msorder_list', + 'GET /api/mgr/orders/{id}/shipment' => 'msorder_list', 'POST /api/mgr/orders' => 'msorder_save', 'DELETE /api/mgr/orders/bulk' => 'msorder_save', 'POST /api/mgr/orders/{id}/finalize' => 'msorder_save', @@ -101,6 +102,7 @@ 'POST /api/mgr/orders/{id}/products' => 'msorder_save', 'PUT /api/mgr/orders/{id}/products/{product_id}' => 'msorder_save', 'DELETE /api/mgr/orders/{id}/products/{product_id}' => 'msorder_save', + 'PUT /api/mgr/orders/{id}/shipment' => 'msorder_save', ]; $actual = []; diff --git a/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php b/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php index 404e09c0c..3bb7f537e 100644 --- a/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php +++ b/core/components/minishop3/tests/ShipmentLifecycleWiringTest.php @@ -93,6 +93,22 @@ $fail('CustomerOrderService must expose shipments[]'); } +$snippet = $read('elements/snippets/ms3_get_order.php'); +if (!str_contains($snippet, 'shipmentPublicForOrder')) { + $fail('ms3_get_order must inject shipments via MiniShop3::shipmentPublicForOrder'); +} +if (substr_count($snippet, '$modx->services') !== 5) { + $fail('ms3_get_order must keep exactly 5 $modx->services hits for phpstan baseline'); +} + +$managerRoutes = $read('config/routes/manager.php'); +if (!str_contains($managerRoutes, "get('/{id}/shipment'")) { + $fail('manager.php missing GET /orders/{id}/shipment'); +} +if (!str_contains($managerRoutes, "put('/{id}/shipment'")) { + $fail('manager.php missing PUT /orders/{id}/shipment'); +} + $settings = $read('../../../_build/elements/settings.php'); if (!str_contains($settings, "'ms3_shipment_enabled'")) { $fail('settings.php missing ms3_shipment_enabled'); @@ -106,5 +122,15 @@ $fail('migration must unique-index order_id'); } +$eventsMigration = $read('migrations/20260819160000_create_shipment_events.php'); +if (!str_contains($eventsMigration, 'uniq_shipment_provider_event')) { + $fail('events migration must unique-index shipment_id + provider_event_id'); +} + +$store = $read('src/Services/Shipment/PdoShipmentStore.php'); +if (!str_contains($store, 'function hasEvent') || !str_contains($store, 'function recordEvent')) { + $fail('PdoShipmentStore must persist webhook event ids'); +} + fwrite(STDOUT, "OK ShipmentLifecycleWiringTest\n"); exit(0); diff --git a/core/components/minishop3/tests/Unit/Controllers/Api/Manager/OrderShipmentControllerTest.php b/core/components/minishop3/tests/Unit/Controllers/Api/Manager/OrderShipmentControllerTest.php new file mode 100644 index 000000000..c5714b21e --- /dev/null +++ b/core/components/minishop3/tests/Unit/Controllers/Api/Manager/OrderShipmentControllerTest.php @@ -0,0 +1,149 @@ +modx()))->get([]); + + self::assertFalse($data['success'] ?? true); + self::assertSame(HttpStatus::BAD_REQUEST, $data['code'] ?? null); + self::assertSame(ApiErrorCode::BAD_REQUEST, $data['error_code'] ?? null); + } + + public function testGetMissingOrderIsNotFound(): void + { + $data = (new OrderShipmentController($this->modx()))->get(['id' => 99]); + + self::assertFalse($data['success'] ?? true); + self::assertSame(HttpStatus::NOT_FOUND, $data['code'] ?? null); + } + + public function testGetReturnsNullShipmentWhenNoneExists(): void + { + $data = (new OrderShipmentController($this->modxWithLifecycle()))->get(['id' => 10]); + + self::assertTrue($data['success'] ?? false); + self::assertArrayHasKey('data', $data); + self::assertIsArray($data['data']); + self::assertArrayHasKey('shipment', $data['data']); + self::assertNull($data['data']['shipment']); + self::assertSame(ShipmentStatus::all(), $data['data']['statuses'] ?? null); + } + + public function testSaveCreatesShipmentAndSetsTracking(): void + { + $data = (new OrderShipmentController($this->modxWithLifecycle()))->save([ + 'id' => 10, + 'tracking_number' => 'TRACK-MGR', + 'status' => ShipmentStatus::PREPARING, + ]); + + self::assertTrue($data['success'] ?? false); + $shipment = $data['data']['shipment'] ?? []; + self::assertSame('TRACK-MGR', $shipment['tracking_number'] ?? null); + self::assertSame(ShipmentStatus::PREPARING, $shipment['status'] ?? null); + self::assertArrayNotHasKey('meta', $shipment); + self::assertArrayNotHasKey('provider', $shipment); + self::assertArrayNotHasKey('external_id', $shipment); + } + + public function testSaveConflictDoesNotLeakSecrets(): void + { + $modx = $this->modxWithLifecycle(); + $controller = new OrderShipmentController($modx); + $controller->save(['id' => 10, 'status' => ShipmentStatus::SHIPPED]); + $data = $controller->save(['id' => 10, 'status' => ShipmentStatus::PREPARING]); + + self::assertFalse($data['success'] ?? true); + self::assertSame(HttpStatus::CONFLICT, $data['code'] ?? null); + $payload = $data['data'] ?? null; + if (is_array($payload)) { + self::assertArrayNotHasKey('meta', $payload); + self::assertArrayNotHasKey('provider', $payload); + } + } + + /** + * @param array $services + */ + private function modx(?msOrder $order = null, array $services = []): modX + { + $order ??= new StubMsOrder(['id' => 10, 'delivery_id' => 7, 'status_id' => 3]); + + return new class ($order, $services) extends modX { + /** + * @param array $map + */ + public function __construct(private msOrder $order, private array $map) + { + parent::__construct(); + $this->services = new class ($this->map) { + /** @param array $map */ + public function __construct(private array $map) + { + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->map) || $key === 'ms3'; + } + + public function get(string $key): mixed + { + return $this->map[$key] ?? null; + } + }; + } + + public function getObject($className, $criteria = null) + { + if ($className !== msOrder::class) { + return null; + } + $id = is_array($criteria) ? (int) ($criteria['id'] ?? 0) : (int) $criteria; + if ($id === (int) $this->order->get('id')) { + return $this->order; + } + + return null; + } + }; + } + + private function modxWithLifecycle(): modX + { + $store = new InMemoryShipmentStore(); + $order = new StubMsOrder(['id' => 10, 'delivery_id' => 7, 'status_id' => 3]); + $orderStatus = $this->createMock(OrderStatusService::class); + $orderStatus->method('change')->willReturn(true); + $lifecycle = new ShipmentLifecycleService($store, $this->modx($order), $orderStatus); + + return $this->modx($order, ['ms3_shipment_lifecycle' => $lifecycle]); + } +} diff --git a/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php b/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php index 04c8f26ff..cb84cc202 100644 --- a/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php +++ b/core/components/minishop3/tests/Unit/Services/Shipment/ShipmentLifecycleServiceTest.php @@ -202,6 +202,36 @@ public function testApplyProviderEventIsIdempotentAndStripsMetaSecrets(): void self::assertSame([[10, 4]], $this->statusChanges); self::assertSame($first['id'], $second['id']); self::assertSame([[10, 4]], $this->statusChanges); + self::assertTrue($store->hasEvent((int) $first['id'], 'hook-1')); + } + + public function testOlderWebhookEventIdIsIgnoredAfterNewerEvent(): void + { + $store = new InMemoryShipmentStore(); + $service = $this->service($store, enabled: true); + $service->create(10); + $service->applyProviderEvent(new ShipmentWebhookEvent( + eventType: ShipmentStatus::SHIPPED, + orderId: 10, + providerEventId: 'evt-a', + ), 7, 'Cdek'); + $service->applyProviderEvent(new ShipmentWebhookEvent( + eventType: ShipmentStatus::IN_TRANSIT, + orderId: 10, + providerEventId: 'evt-b', + ), 7, 'Cdek'); + self::assertSame([[10, 4]], $this->statusChanges); + + $replay = $service->applyProviderEvent(new ShipmentWebhookEvent( + eventType: ShipmentStatus::SHIPPED, + orderId: 10, + trackingNumber: 'SHOULD-NOT-APPLY', + providerEventId: 'evt-a', + ), 7, 'Cdek'); + + self::assertSame(ShipmentStatus::IN_TRANSIT, $replay['status']); + self::assertNull($replay['tracking_number']); + self::assertSame([[10, 4]], $this->statusChanges); } public function testIllegalProviderEventDoesNotPersistTracking(): void @@ -227,6 +257,7 @@ public function testIllegalProviderEventDoesNotPersistTracking(): void self::assertNull($row['tracking_number']); self::assertSame([], $row['meta']); self::assertSame([], $this->statusChanges); + self::assertFalse($store->hasEvent((int) $row['id'], 'hook-bad')); } public function testIllegalFirstWebhookDoesNotCreateShipment(): void diff --git a/core/components/minishop3/tests/support/InMemoryShipmentStore.php b/core/components/minishop3/tests/support/InMemoryShipmentStore.php index 4b61e93d1..95ac2a39e 100644 --- a/core/components/minishop3/tests/support/InMemoryShipmentStore.php +++ b/core/components/minishop3/tests/support/InMemoryShipmentStore.php @@ -14,6 +14,9 @@ final class InMemoryShipmentStore implements ShipmentStoreInterface /** @var array */ private array $rows = []; + /** @var array */ + private array $events = []; + private int $nextId = 1; public function create( @@ -98,4 +101,19 @@ public function findByExternalId(string $provider, string $externalId, ?int $del return null; } + + public function hasEvent(int $shipmentId, string $providerEventId): bool + { + return isset($this->events[$this->eventKey($shipmentId, $providerEventId)]); + } + + public function recordEvent(int $shipmentId, string $providerEventId): void + { + $this->events[$this->eventKey($shipmentId, $providerEventId)] = true; + } + + private function eventKey(int $shipmentId, string $providerEventId): string + { + return $shipmentId . "\0" . $providerEventId; + } } diff --git a/vueManager/src/components/OrderView.vue b/vueManager/src/components/OrderView.vue index befa2e4cb..e3ddce847 100644 --- a/vueManager/src/components/OrderView.vue +++ b/vueManager/src/components/OrderView.vue @@ -19,6 +19,7 @@ import OrderEditProductDialog from './order/OrderEditProductDialog.vue' import OrderHistoryTab from './order/OrderHistoryTab.vue' import OrderInfoTab from './order/OrderInfoTab.vue' import OrderProductsTab from './order/OrderProductsTab.vue' +import OrderShipmentTab from './order/OrderShipmentTab.vue' const { _ } = useLexicon() @@ -175,6 +176,7 @@ defineExpose({ registerPluginTab }) :address-fields-by-section="addressFieldsBySection" :address-extra-fields="addressExtraFields" /> +