diff --git a/assets/components/minishop3/js/web/core/CustomerAPI.js b/assets/components/minishop3/js/web/core/CustomerAPI.js index 7b771821..fc03361d 100644 --- a/assets/components/minishop3/js/web/core/CustomerAPI.js +++ b/assets/components/minishop3/js/web/core/CustomerAPI.js @@ -148,6 +148,40 @@ class CustomerAPI { return this.api.put(`/api/v1/customer/addresses/${id}/set-default`) } + /** + * List current customer orders + * + * GET /api/v1/customer/orders?limit=&offset=&status= + * + * @param {Object} [params] + * @param {number} [params.limit] + * @param {number} [params.offset] + * @param {number} [params.status] + * @returns {Promise} + */ + async getOrders (params = {}) { + const query = new URLSearchParams() + for (const key of ['limit', 'offset', 'status']) { + if (params[key] != null) { + query.set(key, String(params[key])) + } + } + const qs = query.toString() + return this.api.get(`/api/v1/customer/orders${qs ? `?${qs}` : ''}`) + } + + /** + * Get one order owned by the current customer + * + * GET /api/v1/customer/orders/{orderId} + * + * @param {number} orderId - Order ID + * @returns {Promise} + */ + async getOrder (orderId) { + return this.api.get(`/api/v1/customer/orders/${orderId}`) + } + /** * Cancel order * diff --git a/core/components/minishop3/config/routes/web.php b/core/components/minishop3/config/routes/web.php index a3381528..69ebb1ee 100644 --- a/core/components/minishop3/config/routes/web.php +++ b/core/components/minishop3/config/routes/web.php @@ -273,6 +273,16 @@ return $controller->verify($params); }); + $router->get('/orders', function($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\CustomerOrderController($modx); + return $controller->getList($params); + }, [$tokenMiddleware]); + + $router->get('/orders/{id}', function($params) use ($modx) { + $controller = new \MiniShop3\Controllers\Api\Web\CustomerOrderController($modx); + return $controller->get($params); + }, [$tokenMiddleware]); + $router->post('/orders/{id}/cancel', function($params) use ($modx) { $controller = new \MiniShop3\Controllers\Api\Web\CustomerOrderController($modx); return $controller->cancel($params); diff --git a/core/components/minishop3/lexicon/en/customer.inc.php b/core/components/minishop3/lexicon/en/customer.inc.php index 4bf69e7e..8a3b1d43 100644 --- a/core/components/minishop3/lexicon/en/customer.inc.php +++ b/core/components/minishop3/lexicon/en/customer.inc.php @@ -216,3 +216,6 @@ $_lang['ms3_customer_order_cancel_err_not_found'] = 'Order not found'; $_lang['ms3_customer_order_cancel_err_status'] = 'This order cannot be cancelled'; $_lang['ms3_customer_order_cancel_err_failed'] = 'Failed to cancel order'; +$_lang['ms3_customer_order_err_unauthorized'] = 'Authorization required'; +$_lang['ms3_customer_order_err_no_id'] = 'Order ID is required'; +$_lang['ms3_customer_order_err_not_found'] = 'Order not found'; diff --git a/core/components/minishop3/lexicon/ru/customer.inc.php b/core/components/minishop3/lexicon/ru/customer.inc.php index bcc67993..1f33f64b 100644 --- a/core/components/minishop3/lexicon/ru/customer.inc.php +++ b/core/components/minishop3/lexicon/ru/customer.inc.php @@ -216,3 +216,6 @@ $_lang['ms3_customer_order_cancel_err_not_found'] = 'Заказ не найден'; $_lang['ms3_customer_order_cancel_err_status'] = 'Этот заказ нельзя отменить'; $_lang['ms3_customer_order_cancel_err_failed'] = 'Не удалось отменить заказ'; +$_lang['ms3_customer_order_err_unauthorized'] = 'Требуется авторизация'; +$_lang['ms3_customer_order_err_no_id'] = 'Не указан номер заказа'; +$_lang['ms3_customer_order_err_not_found'] = 'Заказ не найден'; diff --git a/core/components/minishop3/src/Controllers/Api/Web/CustomerOrderController.php b/core/components/minishop3/src/Controllers/Api/Web/CustomerOrderController.php index 51b6d000..2a03c798 100644 --- a/core/components/minishop3/src/Controllers/Api/Web/CustomerOrderController.php +++ b/core/components/minishop3/src/Controllers/Api/Web/CustomerOrderController.php @@ -2,17 +2,15 @@ namespace MiniShop3\Controllers\Api\Web; -use MiniShop3\Model\msCustomer; -use MiniShop3\Model\msOrder; use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; -use MiniShop3\Services\Order\OrderStatusService; +use MiniShop3\Services\Customer\CustomerOrderService; use MODX\Revolution\modX; /** * API controller for customer orders (Web API) * - * Handles customer-initiated order actions, e.g. cancel order. + * List / get / cancel for the authorized customer only. * * @package MiniShop3\Controllers\Api\Web */ @@ -29,56 +27,108 @@ public function __construct(modX $modx) } /** - * Cancel order (customer-initiated) - * POST /api/v1/customer/orders/{id}/cancel + * List current customer orders + * GET /api/v1/customer/orders?limit=&offset=&status= + * + * @param array $params URL parameters + * @return array + */ + public function getList(array $params = []): array + { + $customer = $this->getAuthorizedCustomer(); + + if (!$customer) { + return Response::error($this->modx->lexicon('ms3_customer_order_err_unauthorized'), HttpStatus::UNAUTHORIZED)->getData(); + } + + /** @var CustomerOrderService $service */ + $service = $this->modx->services->get('ms3_customer_order'); + $query = CustomerOrderService::normalizeListParams($_GET, $service->getDraftStatusId()); + + return Response::success($service->listForCustomer( + (int)$customer->get('id'), + $query['limit'], + $query['offset'], + $query['status_id'] + ))->getData(); + } + + /** + * Get one order owned by the current customer + * GET /api/v1/customer/orders/{id} * * @param array $params URL parameters (id = order ID) - * @return array Response ['success' => bool, 'message' => '', 'data' => [...]] + * @return array */ - public function cancel(array $params = []): array + public function get(array $params = []): array { $customer = $this->getAuthorizedCustomer(); if (!$customer) { - return Response::error($this->modx->lexicon('ms3_customer_order_cancel_err_unauthorized'), HttpStatus::UNAUTHORIZED)->getData(); + return Response::error($this->modx->lexicon('ms3_customer_order_err_unauthorized'), HttpStatus::UNAUTHORIZED)->getData(); } $orderId = (int)($params['id'] ?? 0); - if (!$orderId) { - return Response::error($this->modx->lexicon('ms3_customer_order_cancel_err_no_order'), HttpStatus::BAD_REQUEST)->getData(); + if ($orderId < 1) { + return Response::error($this->modx->lexicon('ms3_customer_order_err_no_id'), HttpStatus::BAD_REQUEST)->getData(); } - /** @var msOrder|null $order */ - $order = $this->modx->getObject(msOrder::class, [ - 'id' => $orderId, - 'customer_id' => $customer->get('id'), - ]); + /** @var CustomerOrderService $service */ + $service = $this->modx->services->get('ms3_customer_order'); + $detail = $service->getForCustomer((int)$customer->get('id'), $orderId); - if (!$order) { - return Response::error($this->modx->lexicon('ms3_customer_order_cancel_err_not_found'), HttpStatus::NOT_FOUND)->getData(); + if ($detail === null) { + return Response::error($this->modx->lexicon('ms3_customer_order_err_not_found'), HttpStatus::NOT_FOUND)->getData(); } - /** @var OrderStatusService $orderStatusService */ - $orderStatusService = $this->modx->services->get('ms3_order_status'); - $allowedStatusIds = $orderStatusService->getAllowedCancelStatusIds(); - $currentStatusId = (int) $order->get('status_id'); + return Response::success($detail)->getData(); + } - if (!in_array($currentStatusId, $allowedStatusIds, true)) { - return Response::error($this->modx->lexicon('ms3_customer_order_cancel_err_status'), HttpStatus::BAD_REQUEST)->getData(); + /** + * Cancel order (customer-initiated) + * POST /api/v1/customer/orders/{id}/cancel + * + * @param array $params URL parameters (id = order ID) + * @return array Response ['success' => bool, 'message' => '', 'data' => [...]] + */ + public function cancel(array $params = []): array + { + $customer = $this->getAuthorizedCustomer(); + + if (!$customer) { + return Response::error($this->modx->lexicon('ms3_customer_order_cancel_err_unauthorized'), HttpStatus::UNAUTHORIZED)->getData(); } - $cancelledStatusId = (int) $this->modx->getOption('ms3_status_canceled', null, 5); + $orderId = (int)($params['id'] ?? 0); - $result = $orderStatusService->change($orderId, $cancelledStatusId); + if ($orderId < 1) { + return Response::error($this->modx->lexicon('ms3_customer_order_cancel_err_no_order'), HttpStatus::BAD_REQUEST)->getData(); + } - if ($result !== true) { - $message = is_string($result) ? $result : $this->modx->lexicon('ms3_customer_order_cancel_err_failed'); - return Response::error($message, HttpStatus::BAD_REQUEST)->getData(); + /** @var CustomerOrderService $service */ + $service = $this->modx->services->get('ms3_customer_order'); + $result = $service->cancelForCustomer((int)$customer->get('id'), $orderId); + + if (!$result['ok']) { + return match ($result['error']) { + 'not_found' => Response::error( + $this->modx->lexicon('ms3_customer_order_cancel_err_not_found'), + HttpStatus::NOT_FOUND + )->getData(), + 'status' => Response::error( + $this->modx->lexicon('ms3_customer_order_cancel_err_status'), + HttpStatus::BAD_REQUEST + )->getData(), + default => Response::error( + $result['message'] ?? $this->modx->lexicon('ms3_customer_order_cancel_err_failed'), + HttpStatus::BAD_REQUEST + )->getData(), + }; } return Response::success( - ['order_id' => $orderId, 'status_id' => $cancelledStatusId], + ['order_id' => $result['order_id'], 'status_id' => $result['status_id']], $this->modx->lexicon('ms3_customer_order_cancelled') )->getData(); } diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 44840b80..bd252b20 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -87,6 +87,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Order\OrderService::class, 'interface' => null, ], + 'ms3_customer_order' => [ + 'class' => \MiniShop3\Services\Customer\CustomerOrderService::class, + 'interface' => null, + ], // Order workflow services (used by Order controller) // All services can be overridden via ms3.services.php config 'ms3_order_draft_manager' => [ diff --git a/core/components/minishop3/src/Services/Customer/CustomerOrderService.php b/core/components/minishop3/src/Services/Customer/CustomerOrderService.php new file mode 100644 index 00000000..09bf4128 --- /dev/null +++ b/core/components/minishop3/src/Services/Customer/CustomerOrderService.php @@ -0,0 +1,343 @@ +modx = $modx; + $this->modx->lexicon->load('minishop3:manager', 'minishop3:customer'); + } + + /** + * Draft / checkout status id from system setting. + */ + public function getDraftStatusId(): int + { + return (int)$this->modx->getOption('ms3_status_draft', null, self::DEFAULT_DRAFT_STATUS_ID) + ?: self::DEFAULT_DRAFT_STATUS_ID; + } + + /** + * Normalize list query params (limit, offset, status). + * + * @param array $query Typically $_GET + * @param int $draftStatusId Status id to never use as filter (default 1) + * @return array{limit: int, offset: int, status_id: int|null} + */ + public static function normalizeListParams( + array $query, + int $draftStatusId = self::DEFAULT_DRAFT_STATUS_ID + ): array { + $limit = (int)($query['limit'] ?? self::DEFAULT_LIMIT); + $limit = $limit < 1 ? self::DEFAULT_LIMIT : min($limit, self::MAX_LIMIT); + + $statusId = isset($query['status']) ? (int)$query['status'] : null; + if ($statusId === null || $statusId <= 0 || $statusId === $draftStatusId) { + $statusId = null; + } + + return [ + 'limit' => $limit, + 'offset' => max(0, (int)($query['offset'] ?? 0)), + 'status_id' => $statusId, + ]; + } + + /** + * Build public order summary fields from a raw order row. + * + * @param array $orderRow Order field map (e.g. toArray()) + * @param array{name?: string, color?: string} $statusMeta + */ + public static function buildPublicOrderSummary(array $orderRow, array $statusMeta, bool $canCancel): array + { + return [ + 'id' => (int)($orderRow['id'] ?? 0), + 'uuid' => (string)($orderRow['uuid'] ?? ''), + 'num' => (string)($orderRow['num'] ?? ''), + 'createdon' => $orderRow['createdon'] ?? null, + 'updatedon' => $orderRow['updatedon'] ?? null, + 'cost' => (float)($orderRow['cost'] ?? 0), + 'cart_cost' => (float)($orderRow['cart_cost'] ?? 0), + 'delivery_cost' => (float)($orderRow['delivery_cost'] ?? 0), + 'weight' => (float)($orderRow['weight'] ?? 0), + 'status_id' => (int)($orderRow['status_id'] ?? 0), + 'status_name' => (string)($statusMeta['name'] ?? ''), + 'status_color' => (string)($statusMeta['color'] ?? ''), + 'delivery_id' => (int)($orderRow['delivery_id'] ?? 0), + 'payment_id' => (int)($orderRow['payment_id'] ?? 0), + 'context' => (string)($orderRow['context'] ?? ''), + 'order_comment' => (string)($orderRow['order_comment'] ?? ''), + 'can_cancel' => $canCancel, + ]; + } + + /** + * List submitted orders for a customer. + * + * @return array{orders: array, total: int, limit: int, offset: int} + */ + public function listForCustomer(int $customerId, int $limit, int $offset, ?int $statusId = null): array + { + $where = $this->buildCustomerOrdersWhere($customerId, $statusId); + $total = $this->modx->getCount(msOrder::class, $where); + + $statusMap = $this->loadStatusMap(); + /** @var OrderStatusService $orderStatusService */ + $orderStatusService = $this->modx->services->get('ms3_order_status'); + $allowedCancelIds = $orderStatusService->getAllowedCancelStatusIds(); + + $query = $this->modx->newQuery(msOrder::class); + $query->where($where); + $query->sortby('msOrder.createdon', 'DESC'); + $query->limit($limit, $offset); + + $orders = []; + /** @var msOrder $order */ + foreach ($this->modx->getCollection(msOrder::class, $query) as $order) { + $statusIdRow = (int)$order->get('status_id'); + $orders[] = self::buildPublicOrderSummary( + $order->toArray(), + $statusMap[$statusIdRow] ?? ['name' => '', 'color' => ''], + in_array($statusIdRow, $allowedCancelIds, true) + ); + } + + return [ + 'orders' => $orders, + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ]; + } + + /** + * Get one order owned by the customer, with products and related entities. + * + * @return array|null Detail DTO or null if not found / not owned / draft + */ + public function getForCustomer(int $customerId, int $orderId): ?array + { + $order = $this->findCustomerOrder($customerId, $orderId); + if (!$order) { + return null; + } + + /** @var OrderStatusService $orderStatusService */ + $orderStatusService = $this->modx->services->get('ms3_order_status'); + $statusId = (int)$order->get('status_id'); + $status = $order->getOne('Status'); + $statusMeta = [ + 'name' => $status instanceof msOrderStatus + ? $this->translateStatusName((string)$status->get('name')) + : '', + 'color' => $status instanceof msOrderStatus ? (string)$status->get('color') : '', + ]; + + $address = $order->getOne('Address'); + + return [ + 'order' => self::buildPublicOrderSummary( + $order->toArray(), + $statusMeta, + in_array($statusId, $orderStatusService->getAllowedCancelStatusIds(), true) + ), + 'products' => $this->formatOrderProducts((int)$order->get('id')), + 'delivery' => $this->formatDeliveryOrPayment($order->getOne('Delivery') ?: null), + 'payment' => $this->formatDeliveryOrPayment($order->getOne('Payment') ?: null), + 'address' => $address instanceof msOrderAddress ? $this->formatAddress($address) : null, + ]; + } + + /** + * Cancel an order owned by the customer (non-draft, allowed status). + * + * @return array{ok: true, order_id: int, status_id: int}|array{ok: false, error: string, message?: string} + */ + public function cancelForCustomer(int $customerId, int $orderId): array + { + $order = $this->findCustomerOrder($customerId, $orderId); + if (!$order) { + return ['ok' => false, 'error' => 'not_found']; + } + + /** @var OrderStatusService $orderStatusService */ + $orderStatusService = $this->modx->services->get('ms3_order_status'); + $currentStatusId = (int)$order->get('status_id'); + + if (!in_array($currentStatusId, $orderStatusService->getAllowedCancelStatusIds(), true)) { + return ['ok' => false, 'error' => 'status']; + } + + $cancelledStatusId = (int)$this->modx->getOption('ms3_status_canceled', null, 5); + $result = $orderStatusService->change($orderId, $cancelledStatusId); + + if ($result !== true) { + return [ + 'ok' => false, + 'error' => 'failed', + 'message' => is_string($result) ? $result : null, + ]; + } + + return [ + 'ok' => true, + 'order_id' => $orderId, + 'status_id' => $cancelledStatusId, + ]; + } + + /** + * Find non-draft order owned by customer. + */ + protected function findCustomerOrder(int $customerId, int $orderId): ?msOrder + { + if ($orderId < 1 || $customerId < 1) { + return null; + } + + /** @var msOrder|null $order */ + $order = $this->modx->getObject(msOrder::class, [ + 'id' => $orderId, + 'customer_id' => $customerId, + 'status_id:!=' => $this->getDraftStatusId(), + ]); + + return $order ?: null; + } + + protected function buildCustomerOrdersWhere(int $customerId, ?int $statusId): array + { + $draftStatusId = $this->getDraftStatusId(); + $where = [ + 'customer_id' => $customerId, + 'status_id:!=' => $draftStatusId, + ]; + + if ($statusId !== null && $statusId !== $draftStatusId) { + $where['status_id'] = $statusId; + unset($where['status_id:!=']); + } + + return $where; + } + + /** + * @return array + */ + protected function loadStatusMap(): array + { + $query = $this->modx->newQuery(msOrderStatus::class); + $query->sortby('position', 'ASC'); + + $map = []; + /** @var msOrderStatus $status */ + foreach ($this->modx->getIterator(msOrderStatus::class, $query) as $status) { + $map[(int)$status->get('id')] = [ + 'name' => $this->translateStatusName((string)$status->get('name')), + 'color' => (string)$status->get('color'), + ]; + } + + return $map; + } + + protected function translateStatusName(string $name): string + { + if (!str_starts_with($name, 'ms3_order_status_')) { + return $name; + } + + $translated = $this->modx->lexicon($name); + + return $translated !== $name ? $translated : $name; + } + + /** + * @return array + */ + protected function formatOrderProducts(int $orderId): array + { + $query = $this->modx->newQuery(msOrderProduct::class); + $query->where(['order_id' => $orderId]); + $query->sortby('msOrderProduct.id', 'ASC'); + + $products = []; + /** @var msOrderProduct $product */ + foreach ($this->modx->getCollection(msOrderProduct::class, $query) as $product) { + $options = $product->get('options'); + $products[] = [ + 'id' => (int)$product->get('id'), + 'product_id' => (int)$product->get('product_id'), + 'name' => (string)$product->get('name'), + 'count' => (int)$product->get('count'), + 'price' => (float)$product->get('price'), + 'weight' => (float)$product->get('weight'), + 'cost' => (float)$product->get('cost'), + 'options' => is_array($options) ? $options : [], + ]; + } + + return $products; + } + + protected function formatDeliveryOrPayment(?object $entity): ?array + { + if (!$entity) { + return null; + } + + return [ + 'id' => (int)$entity->get('id'), + 'name' => (string)$entity->get('name'), + 'description' => (string)$entity->get('description'), + 'price' => $entity->get('price'), + 'logo' => (string)$entity->get('logo'), + ]; + } + + protected function formatAddress(msOrderAddress $address): array + { + return [ + 'first_name' => (string)$address->get('first_name'), + 'last_name' => (string)$address->get('last_name'), + 'phone' => (string)$address->get('phone'), + 'email' => (string)$address->get('email'), + 'country' => (string)$address->get('country'), + 'index' => (string)$address->get('index'), + 'region' => (string)$address->get('region'), + 'city' => (string)$address->get('city'), + 'metro' => (string)$address->get('metro'), + 'street' => (string)$address->get('street'), + 'building' => (string)$address->get('building'), + 'entrance' => (string)$address->get('entrance'), + 'floor' => (string)$address->get('floor'), + 'room' => (string)$address->get('room'), + 'comment' => (string)$address->get('comment'), + 'text_address' => (string)$address->get('text_address'), + ]; + } +} diff --git a/core/components/minishop3/tests/CustomerOrderServiceDtoTest.php b/core/components/minishop3/tests/CustomerOrderServiceDtoTest.php new file mode 100644 index 00000000..bea07610 --- /dev/null +++ b/core/components/minishop3/tests/CustomerOrderServiceDtoTest.php @@ -0,0 +1,100 @@ + 999, 'offset' => -5, 'status' => 0]); +$assertSame(CustomerOrderService::MAX_LIMIT, $capped['limit'], 'limit capped at MAX'); +$assertSame(0, $capped['offset'], 'negative offset → 0'); +$assertSame(null, $capped['status_id'], 'status 0 ignored'); + +$draftFilter = CustomerOrderService::normalizeListParams( + ['status' => CustomerOrderService::DEFAULT_DRAFT_STATUS_ID], + CustomerOrderService::DEFAULT_DRAFT_STATUS_ID +); +$assertSame(null, $draftFilter['status_id'], 'draft status filter ignored'); + +$customDraft = CustomerOrderService::normalizeListParams(['status' => 9], 9); +$assertSame(null, $customDraft['status_id'], 'custom draft id ignored as filter'); + +$statusWhenDraftIsNine = CustomerOrderService::normalizeListParams(['status' => 1], 9); +$assertSame(1, $statusWhenDraftIsNine['status_id'], 'status 1 allowed when draft is 9'); + +$valid = CustomerOrderService::normalizeListParams(['limit' => 10, 'offset' => 20, 'status' => 3]); +$assertSame(10, $valid['limit'], 'custom limit'); +$assertSame(20, $valid['offset'], 'custom offset'); +$assertSame(3, $valid['status_id'], 'status filter'); + +// --- buildPublicOrderSummary: no internals --- + +$dto = CustomerOrderService::buildPublicOrderSummary( + [ + 'id' => 42, + 'uuid' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'num' => '2601/1', + 'createdon' => '2026-07-17 12:00:00', + 'updatedon' => '2026-07-17 12:05:00', + 'cost' => 1500.5, + 'cart_cost' => 1400, + 'delivery_cost' => 100.5, + 'weight' => 1.2, + 'status_id' => 2, + 'delivery_id' => 1, + 'payment_id' => 2, + 'context' => 'web', + 'order_comment' => 'leave at door', + 'token' => 'secret-token-must-not-leak', + 'properties' => ['internal' => true], + 'user_id' => 99, + 'customer_id' => 7, + ], + ['name' => 'New', 'color' => '#00aa00'], + true +); + +$assertSame(42, $dto['id'], 'dto id'); +$assertSame('2601/1', $dto['num'], 'dto num'); +$assertSame(1500.5, $dto['cost'], 'dto cost'); +$assertSame('New', $dto['status_name'], 'dto status_name'); +$assertSame('#00aa00', $dto['status_color'], 'dto status_color'); +$assertSame(true, $dto['can_cancel'], 'dto can_cancel'); +$assertTrue(!array_key_exists('token', $dto), 'token must not be in DTO'); +$assertTrue(!array_key_exists('properties', $dto), 'properties must not be in DTO'); +$assertTrue(!array_key_exists('user_id', $dto), 'user_id must not be in DTO'); +$assertTrue(!array_key_exists('customer_id', $dto), 'customer_id must not be in DTO'); + +fwrite(STDOUT, "OK CustomerOrderServiceDtoTest\n"); +exit(0);