From 6566b99995c770111e2a6f07eecd6013d31d1152 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 30 Jul 2026 10:00:26 +0600 Subject: [PATCH 1/2] refactor(orders): decompose Manager OrdersController and add ServiceRegistry factories Extract list/mutation/products/presenter services to shrink OrdersController below 1k LOC; register manager order services in DI. Replace ServiceRegistry switch/in_array wiring with explicit ServiceRegistryFactories map. --- .../Api/Manager/OrdersController.php | 1616 ++--------------- .../minishop3/src/ServiceRegistry.php | 161 +- .../src/ServiceRegistryFactories.php | 156 ++ .../Order/ManagerOrderListService.php | 355 ++++ .../Order/ManagerOrderMutationService.php | 476 +++++ .../Services/Order/ManagerOrderPresenter.php | 246 +++ .../Order/ManagerOrderProductsService.php | 506 ++++++ .../tests/ManagerOrdersDecompositionTest.php | 73 + .../tests/ServiceRegistryFactoryMapTest.php | 50 + 9 files changed, 2010 insertions(+), 1629 deletions(-) create mode 100644 core/components/minishop3/src/ServiceRegistryFactories.php create mode 100644 core/components/minishop3/src/Services/Order/ManagerOrderListService.php create mode 100644 core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php create mode 100644 core/components/minishop3/src/Services/Order/ManagerOrderPresenter.php create mode 100644 core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php create mode 100644 core/components/minishop3/tests/ManagerOrdersDecompositionTest.php create mode 100644 core/components/minishop3/tests/ServiceRegistryFactoryMapTest.php diff --git a/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php b/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php index 283e347f..88a989c6 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/OrdersController.php @@ -3,94 +3,44 @@ namespace MiniShop3\Controllers\Api\Manager; use MiniShop3\MiniShop3; -use MiniShop3\Model\msDelivery; -use MiniShop3\Model\msExtraField; -use MiniShop3\Model\msModelField; use MiniShop3\Model\msOrder; -use MiniShop3\Model\msOrderAddress; use MiniShop3\Model\msOrderLog; -use MiniShop3\Model\msOrderStatus; -use MiniShop3\Model\msPayment; use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; -use MiniShop3\Services\ExtraFields\RepeaterFieldService; -use MiniShop3\Services\CustomerDuplicateChecker; -use MiniShop3\Services\CustomerFactory; -use MiniShop3\Services\FilterConfigManager; -use MiniShop3\Services\Grid\ManagerListFilterPolicy; use MiniShop3\Services\Order\ManagerOrderCostRecalculator; +use MiniShop3\Services\Order\ManagerOrderListService; +use MiniShop3\Services\Order\ManagerOrderMutationService; +use MiniShop3\Services\Order\ManagerOrderPresenter; +use MiniShop3\Services\Order\ManagerOrderProductsService; use MiniShop3\Services\Order\OrderLogService; -use MiniShop3\Services\Order\OrderService; -use MiniShop3\Services\Order\OrderStatusService; -use MiniShop3\Utils\Utils; -use MODX\Revolution\modSystemEvent; -use MODX\Revolution\modSystemSetting; +use MODX\Revolution\modUser; use MODX\Revolution\modX; /** * API controller for order management (Manager API) * - * Handles CRUD operations for orders in admin panel. - * - * Order line items: msOnCreateOrderProduct, msOnUpdateOrderProduct, msOnRemoveOrderProduct run AFTER - * save/remove. A failing "after" plugin cannot roll back persistence; we therefore log its error - * but never propagate a 4xx response to the client — that would mislead the user into thinking - * the action failed when in fact the row is already in DB. Veto or validation belongs in the - * matching msOnBefore* handlers, which DO short-circuit with an HTTP error before persistence. + * Thin HTTP layer that validates route params and delegates business logic + * to dedicated manager order services. * * @package MiniShop3\Controllers\Api\Manager */ class OrdersController { - protected const DIRECT_FILTER_KEYS = [ - 'query', // handled separately in getList(), not via applyDirectFilters - 'status_id', - 'delivery_id', - 'payment_id', - 'context_key', - 'createdon_from', // date keys handled in applyDirectFilters(), not in FIELD_MAP - 'createdon_to', - ]; - - protected const DIRECT_FILTER_INT_KEYS = [ - 'status_id', - 'delivery_id', - 'payment_id', - ]; - - /** Param key => msOrder column for direct (unprefixed) filter params. */ - protected const DIRECT_FILTER_FIELD_MAP = [ - 'status_id' => 'status_id', - 'delivery_id' => 'delivery_id', - 'payment_id' => 'payment_id', - 'context_key' => 'context', - ]; - - protected const ADDRESS_FILTER_KEYS = [ - 'customer', - 'email', - 'phone', - ]; - - /** - * Internal order fields never exposed in Manager API responses. - * - * `token` is the secret used to authorize web order actions (see Web OrderController - * `ms3_token`); the Vue manager never consumes it, so it must not leak in any order payload. - */ - protected const HIDDEN_ORDER_FIELDS = [ - 'token', - ]; + protected const DIRECT_FILTER_KEYS = ManagerOrderListService::DIRECT_FILTER_KEYS; + protected const DIRECT_FILTER_FIELD_MAP = ManagerOrderListService::DIRECT_FILTER_FIELD_MAP; + protected const HIDDEN_ORDER_FIELDS = ManagerOrderPresenter::HIDDEN_ORDER_FIELDS; protected modX $modx; protected ?OrderLogService $orderLog = null; - protected ?Utils $ms3Utils = null; + protected ?ManagerOrderPresenter $presenter = null; + protected ?ManagerOrderListService $listService = null; + protected ?ManagerOrderMutationService $mutationService = null; + protected ?ManagerOrderProductsService $productsService = null; + protected ?ManagerOrderCostRecalculator $costRecalculator = null; public function __construct(modX $modx) { $this->modx = $modx; - - // Ensure extra fields are loaded into xPDO map $this->loadExtraFieldsMap(); } @@ -100,67 +50,8 @@ public static function getDirectFilterKeys(): array } /** - * Get OrderLogService (lazy loading from DI) - * - * @return OrderLogService - */ - protected function getOrderLog(): OrderLogService - { - if ($this->orderLog === null) { - if ($this->modx->services->has('ms3_order_log')) { - $this->orderLog = $this->modx->services->get('ms3_order_log'); - } else { - /** @var MiniShop3 $ms3 */ - $ms3 = $this->modx->services->get('ms3'); - $this->orderLog = new OrderLogService($this->modx, $ms3); - } - } - return $this->orderLog; - } - - /** - * @return Utils MiniShop3 utils (invokeEvent with success/message handling) - */ - protected function getMs3Utils(): Utils - { - if ($this->ms3Utils === null) { - /** @var MiniShop3 $ms3 */ - $ms3 = $this->modx->services->get('ms3'); - $this->ms3Utils = $ms3->utils; - } - - return $this->ms3Utils; - } - - /** - * Merge address fields into order payload without overwriting order-level fields. - * - * msOrderAddress has its own `id`, `properties`, `createdon`, `updatedon` which - * must not overwrite the corresponding msOrder fields in the API response. - * - * Note: extra fields for msOrderAddress are loaded separately in get() since - * they require explicit column reads; create()/finalize() return transient - * responses where the address is either empty or just created. - */ - protected function mergeAddressIntoOrderData(array $orderData, ?\xPDO\Om\xPDOObject $address): array - { - if (!$address) { - return $orderData; - } - - $excludeFields = ['id', 'order_id', 'createdon', 'updatedon', 'properties']; - foreach ($address->toArray() as $key => $value) { - if (!in_array($key, $excludeFields, true)) { - $orderData[$key] = $value; - } - } - - return $orderData; - } - - /** - * Load extra fields into xPDO map - * This ensures dynamic columns added via Object Extension are available + * Load extra fields into xPDO map. + * This ensures dynamic columns added via Object Extension are available. */ protected function loadExtraFieldsMap(): void { @@ -170,143 +61,24 @@ protected function loadExtraFieldsMap(): void } } - /** - * Get list of orders with pagination, search and filters - * GET /api/mgr/orders - * - * @param array $params URL parameters (start, limit, query, status_id, customer, context, date_start, date_end) - * @return array Response - */ public function getList(array $params = []): array { - $start = (int)($params['start'] ?? 0); - $limit = (int)($params['limit'] ?? 20); - $query = trim($params['query'] ?? ''); - $sort = $params['sort'] ?? 'id'; - $dir = strtoupper($params['dir'] ?? 'DESC'); - - $gridConfig = $this->modx->services->get('ms3_grid_config'); - // Get ALL fields including hidden (for relation JOINs) - $gridFields = $gridConfig ? $gridConfig->getGridConfig('orders', true) : []; - - $c = $this->modx->newQuery(msOrder::class); - - // Address JOIN is always needed for search and customer info - $c->leftJoin(msOrderAddress::class, 'Address', '`Address`.order_id = msOrder.id'); - - // Dynamic JOINs from relation fields in grid config - $relationGroups = $gridConfig ? $gridConfig->extractRelationFields($gridFields) : []; - foreach ($relationGroups as $group) { - if (!empty($group['modelClass'])) { - $c->leftJoin($group['modelClass'], $group['alias'], "`{$group['alias']}`.id = msOrder.{$group['foreignKey']}"); - } - } - - $this->applyDraftVisibilityFilter($c, $params); - - if (!empty($query)) { - if (is_numeric($query)) { - $c->andCondition([ - 'id' => $query, - 'OR:Address.phone:LIKE' => "%{$query}%", - ]); - } else { - $c->where([ - 'num:LIKE' => "{$query}%", - 'OR:order_comment:LIKE' => "%{$query}%", - 'OR:Address.comment:LIKE' => "%{$query}%", - 'OR:Address.first_name:LIKE' => "%{$query}%", - 'OR:Address.last_name:LIKE' => "%{$query}%", - 'OR:Address.email:LIKE' => "%{$query}%", - 'OR:Address.phone:LIKE' => "%{$query}%", - ]); - } - } - - foreach ($params as $key => $value) { - if (str_starts_with($key, 'filter_') && $value !== '' && $value !== null) { - $fieldName = substr($key, 7); - $this->applyFilter($c, $fieldName, $value); - } - } - - $this->applyDirectFilters($c, $params); - - // Legacy params for backward compatibility - if ($customer = ($params['customer'] ?? null)) { - $c->where(['customer_id' => (int)$customer]); - } - if ($context = ($params['context'] ?? null)) { - $c->where(['context' => $context]); - } - - $countQuery = clone $c; - $countQuery->select('COUNT(DISTINCT msOrder.id)'); - $countQuery->prepare(); - $countQuery->stmt->execute(); - $total = (int)$countQuery->stmt->fetchColumn(); - - // Build SELECT: base model fields + address fields + dynamic relation fields - $selectParts = [ - $this->modx->getSelectColumns(msOrder::class, 'msOrder'), - '`Address`.first_name', '`Address`.last_name', '`Address`.phone', '`Address`.email', - ]; - - // Add SELECT for relation fields - foreach ($relationGroups as $group) { - foreach ($group['fields'] as $fieldDef) { - $selectParts[] = "`{$group['alias']}`.{$fieldDef['displayField']} as `{$fieldDef['name']}`"; - } - } - - $c->select(implode(', ', $selectParts)); - - $sortField = $this->mapSortField($sort); - $c->sortby($sortField, $dir); - - if ($limit > 0) { - $c->limit($limit, $start); - } - - $c->prepare(); - $rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : []; - - $results = []; - foreach ($rows as $row) { - $results[] = $this->formatOrder($row); - } - - $stats = $this->getOrdersStats($params); - - return Response::success([ - 'results' => $results, - 'total' => $total, - 'stats' => $stats - ])->getData(); + return Response::success($this->getListService()->getList($params))->getData(); } - /** - * Get specific order - * GET /api/mgr/orders/{id} - * - * @param array $params URL parameters (id) - * @return array Response - */ public function get(array $params = []): array { $id = (int)($params['id'] ?? 0); - if (!$id) { return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); } $order = $this->modx->getObject(msOrder::class, $id); - if (!$order) { return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); } - return Response::success($this->buildOrderPayloadFromModel($order))->getData(); + return Response::success($this->getPresenter()->buildOrderPayloadFromModel($order))->getData(); } /** @@ -331,9 +103,6 @@ public function recalculateCost(array $params = []): array return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); } - /** @var MiniShop3 $ms3 */ - $ms3 = $this->modx->services->get('ms3'); - $modeIn = strtolower(trim((string)($params['mode'] ?? ManagerOrderCostRecalculator::MODE_AUTO))); $allowedModes = [ ManagerOrderCostRecalculator::MODE_AUTO, @@ -349,32 +118,29 @@ public function recalculateCost(array $params = []): array $options['manual_delivery_cost'] = $params['manual_delivery_cost']; } - $recalculator = new ManagerOrderCostRecalculator($this->modx, $ms3); - $result = $recalculator->recalculate($order, $options); - + $result = $this->getOrderCostRecalculator()->recalculate($order, $options); if (empty($result['success'])) { return Response::error( - $this->lexiconMessageOrKey((string)($result['message'] ?? 'ms3_err_unknown')), + $this->getPresenter()->lexiconMessageOrKey((string)($result['message'] ?? 'ms3_err_unknown')), HttpStatus::BAD_REQUEST )->getData(); } $serviceData = is_array($result['data']) ? $result['data'] : []; - /** @var msOrder|false $fresh */ $fresh = $this->modx->getObject(msOrder::class, $id); if (!$fresh instanceof msOrder) { return Response::error('Order not found after recalculation', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); } - $payload = $this->buildOrderPayloadFromModel($fresh); + $payload = $this->getPresenter()->buildOrderPayloadFromModel($fresh); if (!empty($serviceData['breakdown'])) { $payload['breakdown'] = $serviceData['breakdown']; } $payload['warnings'] = $serviceData['warnings'] ?? []; $messageKey = 'ms3_order_cost_recalc_success'; - $message = $this->lexiconMessageOrKey($messageKey); + $message = $this->getPresenter()->lexiconMessageOrKey($messageKey); if ($message === $messageKey) { $message = ''; } @@ -382,337 +148,28 @@ public function recalculateCost(array $params = []): array return Response::success($payload, $message)->getData(); } - /** Human-readable lexicon entry, or original key when missing/empty translation. */ - protected function lexiconMessageOrKey(string $key): string - { - $text = $this->modx->lexicon($key); - - return ($text !== $key && $text !== '') ? $text : $key; - } - - /** - * Снимок данных заказа для API карточки (как в {@see get()} после загрузки). - */ - protected function buildOrderPayloadFromModel(msOrder $order): array - { - $status = $order->getOne('Status'); - $delivery = $order->getOne('Delivery'); - $payment = $order->getOne('Payment'); - $address = $order->getOne('Address'); - - $data = $order->toArray(); - $data['status_name'] = $status ? $status->get('name') : ''; - $data['color'] = $status ? $status->get('color') : ''; - $data['delivery_name'] = $delivery ? $delivery->get('name') : ''; - $data['payment_name'] = $payment ? $payment->get('name') : ''; - - $orderExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrder'); - foreach ($orderExtraFields as $fieldKey) { - $data[$fieldKey] = $order->get($fieldKey); - } - - $data = $this->mergeAddressIntoOrderData($data, $address); - - if ($address) { - $addressExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrderAddress'); - foreach ($addressExtraFields as $fieldKey) { - $data[$fieldKey] = $address->get($fieldKey); - } - } - - return $this->formatOrder($data); - } - - /** - * Delete order - * DELETE /api/mgr/orders/{id} - * - * @param array $params URL parameters (id) - * @return array Response - */ public function delete(array $params = []): array { - $id = (int)($params['id'] ?? 0); - - if (!$id) { - return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); - } - - $order = $this->modx->getObject(msOrder::class, $id); - - if (!$order) { - return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); - } - - $addresses = $this->modx->getIterator(msOrderAddress::class, ['order_id' => $id]); - foreach ($addresses as $address) { - $address->remove(); - } - - $products = $this->modx->getIterator(\MiniShop3\Model\msOrderProduct::class, ['order_id' => $id]); - foreach ($products as $product) { - $product->remove(); - } - - if (!$order->remove()) { - return Response::error('Failed to delete order', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - return Response::success([], 'Order deleted successfully')->getData(); + return $this->wrapServiceResult($this->getMutationService()->delete($params)); } - /** - * Bulk delete orders - * DELETE /api/mgr/orders/bulk - * - * @param array $data Request data (ids) - * @return array Response - */ - public function bulkDelete(array $data = []): array + public function bulkDelete(array $params = []): array { - $ids = $data['ids'] ?? []; - - if (empty($ids) || !is_array($ids)) { - return Response::error('Order IDs array is required', HttpStatus::BAD_REQUEST)->getData(); - } - - // Sanitize IDs - $ids = array_filter(array_map('intval', $ids), function ($id) { - return $id > 0; - }); - - if (empty($ids)) { - return Response::error('No valid order IDs provided', HttpStatus::BAD_REQUEST)->getData(); - } - - $deleted = 0; - $failed = 0; - - foreach ($ids as $id) { - $order = $this->modx->getObject(msOrder::class, $id); - - if (!$order) { - $failed++; - continue; - } - - // Delete related addresses - $addresses = $this->modx->getIterator(msOrderAddress::class, ['order_id' => $id]); - foreach ($addresses as $address) { - $address->remove(); - } - - // Delete related products - $products = $this->modx->getIterator(\MiniShop3\Model\msOrderProduct::class, ['order_id' => $id]); - foreach ($products as $product) { - $product->remove(); - } - - // Delete related logs - $logs = $this->modx->getIterator(\MiniShop3\Model\msOrderLog::class, ['order_id' => $id]); - foreach ($logs as $log) { - $log->remove(); - } - - if ($order->remove()) { - $deleted++; - } else { - $failed++; - } - } - - if ($deleted === 0) { - return Response::error('Failed to delete orders', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - return Response::success([ - 'deleted' => $deleted, - 'failed' => $failed - ], "Deleted {$deleted} orders")->getData(); + return $this->wrapServiceResult($this->getMutationService()->bulkDelete($params)); } - /** - * Create new order - * POST /api/mgr/orders - * - * @param array $params Body parameters - * - create_customer: bool - Create customer from order data - * - force_create_customer: bool - Create customer even if duplicate found - * - customer_id: int - Existing customer ID (if not creating new) - * - first_name, last_name, email, phone: Customer/address data - * - delivery_id, payment_id: Optional delivery and payment - * @return array Response - */ public function create(array $params = []): array { - $createCustomer = !empty($params['create_customer']); - $forceCreateCustomer = !empty($params['force_create_customer']); - $customerId = (int) ($params['customer_id'] ?? 0); - - // Handle customer creation - if ($createCustomer && $customerId === 0) { - // Validate customer data - need at least email or phone - $email = trim($params['email'] ?? ''); - $phone = trim($params['phone'] ?? ''); - - if (empty($email) && empty($phone)) { - return Response::error( - $this->modx->lexicon('ms3_order_err_customer_contact'), - 400, - ['email', 'phone'] - )->getData(); - } - - /** @var CustomerDuplicateChecker $duplicateChecker */ - $duplicateChecker = $this->modx->services->get('ms3_customer_duplicate_checker'); - - // Check for duplicates (unless forcing creation) - if (!$forceCreateCustomer && $duplicateChecker->hasCheckableData($params)) { - $existingCustomer = $duplicateChecker->findDuplicate($params); - - if ($existingCustomer) { - // Return duplicate info for user decision - return Response::success([ - 'duplicate_found' => true, - 'customer' => [ - 'id' => $existingCustomer->get('id'), - 'first_name' => $existingCustomer->get('first_name'), - 'last_name' => $existingCustomer->get('last_name'), - 'email' => $existingCustomer->get('email'), - 'phone' => $existingCustomer->get('phone'), - 'orders_count' => $existingCustomer->get('orders_count'), - 'total_spent' => $existingCustomer->get('total_spent'), - ], - ], 'Customer with matching data already exists')->getData(); - } - } - - // Create new customer - try { - /** @var CustomerFactory $customerFactory */ - $customerFactory = $this->modx->services->get('ms3_customer_factory'); - $customer = $customerFactory->createFromOrderData($params); - $customerId = $customer->get('id'); - - $this->modx->log( - modX::LOG_LEVEL_INFO, - "[OrdersController] Created new customer #{$customerId} from order data" - ); - } catch (\Exception $e) { - $this->modx->log( - modX::LOG_LEVEL_ERROR, - "[OrdersController] Failed to create customer: " . $e->getMessage() - ); - return Response::error('Failed to create customer: ' . $e->getMessage(), HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - } - - // Create new order - $order = $this->modx->newObject(msOrder::class); - - // Generate UUID and token - $order->set('uuid', (string) \Ramsey\Uuid\Uuid::uuid4()); - $order->set('token', md5(uniqid('ms3_mgr_', true))); - - // Set status to "Draft" - order will be finalized later - $statusDraft = (int) $this->modx->getOption('ms3_status_draft', null, 1) ?: 1; - $order->set('status_id', $statusDraft); - - // Context - $order->set('context', $params['context'] ?? 'web'); - - // Timestamps - $order->set('createdon', time()); - $order->set('updatedon', time()); - - // User who creates the order (manager) - $order->set('user_id', $this->modx->user->get('id')); - - // Customer ID (from existing, selected, or newly created) - $order->set('customer_id', $customerId); - $order->set('delivery_id', (int) ($params['delivery_id'] ?? 0)); - $order->set('payment_id', (int) ($params['payment_id'] ?? 0)); - $order->set('order_comment', $params['order_comment'] ?? ''); - - // Order number will be generated on finalization (NULL so UNIQUE allows many drafts) - $order->set('num', null); - - // Initial costs (will be recalculated after adding products) - $order->set('cart_cost', 0); - /** @var OrderService $orderService */ - $orderService = $this->modx->services->get('ms3_order_service'); - $deliveryCost = (float) ($params['delivery_cost'] ?? 0); - $order->set('delivery_cost', $deliveryCost); - $order->set('cost', $orderService->clampComputedTotal(null, 0.0, $deliveryCost, 0.0)); - $order->set('weight', 0); - - if (!$order->save()) { - return Response::error('Failed to create order', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - // Create empty address - $address = $this->modx->newObject(msOrderAddress::class); - $address->set('order_id', $order->get('id')); - $address->set('createdon', time()); - - // Address fields from params - // Safe to use array_key_exists: new entity, no previous value to silently overwrite; - // all address columns are nullable VARCHAR/TEXT. - $addressFields = [ - 'first_name', 'last_name', 'phone', 'email', - 'country', 'index', 'region', 'city', 'metro', - 'street', 'building', 'entrance', 'floor', 'room', - 'comment', 'text_address' - ]; - foreach ($addressFields as $field) { - if (array_key_exists($field, $params)) { - $address->set($field, $params[$field]); - } - } - $address->save(); - - // Log draft creation - $this->getOrderLog()->addEntry( - $order->get('id'), - msOrderLog::ACTION_STATUS, - [ - 'old_status_id' => 0, - 'new_status_id' => $statusDraft, - 'old_status_name' => '', - 'new_status_name' => $this->getStatusName($statusDraft), - ] - ); - - // Return created order with address data - $orderData = $order->toArray(); - $orderData = $this->mergeAddressIntoOrderData($orderData, $address); - $orderData['customer_created'] = $createCustomer && $customerId > 0; - - return Response::success($this->formatOrder($orderData), 'Order draft created')->getData(); + return $this->wrapServiceResult($this->getMutationService()->create($params)); } /** * Finalize order (convert draft to final order) * POST /api/mgr/orders/{id}/finalize - * - * Triggers: - * - Validation (products, delivery, payment) - * - Cost calculation - * - Order number generation - * - Status change to "New" - * - Events (msOnBeforeCreateOrder, msOnCreateOrder, msOnChangeOrderStatus) - * - Notifications - * - * @param array $params Route parameters - * - id: Order ID - * - skip_notifications: bool - Skip sending notifications (optional) - * - create_customer: bool - Create customer from order address data (optional) - * - force_create_customer: bool - Force create even if duplicate found (optional) - * @return array Response */ public function finalize(array $params = []): array { - $orderId = (int) ($params['id'] ?? 0); + $orderId = (int)($params['id'] ?? 0); if (!$orderId) { return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); } @@ -723,9 +180,7 @@ public function finalize(array $params = []): array 'force_create_customer' => !empty($params['force_create_customer']), ]; - /** @var \MiniShop3\Services\Order\OrderFinalizeService $finalizeService */ $finalizeService = $this->modx->services->get('ms3_order_finalize'); - $result = $finalizeService->finalize($orderId, $options); // Check if duplicate customer was found - return for user decision @@ -734,7 +189,6 @@ public function finalize(array $params = []): array } if (!$result['success']) { - // Translate error message $message = $result['message'] ?? 'ms3_err_unknown'; $translatedMessage = $this->modx->lexicon($message); if ($translatedMessage === $message) { @@ -744,670 +198,54 @@ public function finalize(array $params = []): array return Response::error($translatedMessage, HttpStatus::BAD_REQUEST, $result['data'] ?? [])->getData(); } - // Reload order with full data $order = $this->modx->getObject(msOrder::class, $orderId); if (!$order) { return Response::error('Order not found after finalization', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); } - $orderData = $order->toArray(); - - // Get address data - $address = $order->getOne('Address'); - $orderData = $this->mergeAddressIntoOrderData($orderData, $address); - - return Response::success($this->formatOrder($orderData), 'ms3_order_finalized')->getData(); - } - - /** - * Get status name by ID - * - * @param int $statusId Status ID - * @return string Status name - */ - protected function getStatusName(int $statusId): string - { - $status = $this->modx->getObject(msOrderStatus::class, $statusId); - if (!$status) { - return (string) $statusId; - } - - $name = $status->get('name'); - if (str_starts_with($name, 'ms3_order_status_')) { - $translated = $this->modx->lexicon($name); - if ($translated !== $name) { - return $translated; - } - } - - return $name; + return Response::success( + $this->getPresenter()->buildOrderPayloadFromModel($order), + 'ms3_order_finalized' + )->getData(); } - /** - * Update order - * PUT /api/mgr/orders/{id} - * - * @param array $params URL and body parameters - * @return array Response - */ public function update(array $params = []): array { - $id = (int)($params['id'] ?? 0); - - if (!$id) { - return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); - } - - $order = $this->modx->getObject(msOrder::class, $id); - - if (!$order) { - return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Store old values for logging - $oldStatusId = $order->get('status_id'); - $oldOrderValues = $order->toArray(); - - // Get editable order fields from msModelField configuration - $orderFields = $this->getModelFieldNames('msOrder'); - $changedOrderFields = []; - - foreach ($orderFields as $field) { - if (array_key_exists($field, $params)) { - $oldValue = $order->get($field); - $newValue = $params[$field]; - if ($oldValue != $newValue) { - $changedOrderFields[$field] = ['old' => $oldValue, 'new' => $newValue]; - } - $order->set($field, $newValue); - } - } - - // Handle extra fields for msOrder (stored as real DB columns via Object Extension) - $orderExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrder'); - foreach ($orderExtraFields as $extraFieldKey) { - if (!array_key_exists($extraFieldKey, $params)) { - continue; - } - - $normalized = $this->normalizeExtraFieldValue( - 'MiniShop3\\Model\\msOrder', - $extraFieldKey, - $params[$extraFieldKey] - ); - if (!$normalized['ok']) { - return Response::error( - $normalized['message'], - HttpStatus::UNPROCESSABLE_ENTITY - )->getData(); - } - - $newValue = $normalized['value']; - $oldValue = $order->get($extraFieldKey); - if ($oldValue != $newValue) { - $changedOrderFields[$extraFieldKey] = ['old' => $oldValue, 'new' => $newValue]; - } - $order->set($extraFieldKey, $newValue); - } - - $order->set('updatedon', date('Y-m-d H:i:s')); - - if (!$order->save()) { - return Response::error('Failed to update order', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - // Log order field changes (excluding status_id which is logged separately) - unset($changedOrderFields['status_id']); - if (!empty($changedOrderFields)) { - $this->getOrderLog()->addEntry( - $id, - msOrderLog::ACTION_FIELD, - ['fields' => $changedOrderFields] - ); - } - - // Handle address fields - $address = $this->modx->getObject(msOrderAddress::class, ['order_id' => $id]); - if ($address) { - $oldAddressValues = $address->toArray(); - $changedAddressFields = []; - - // Get editable address fields from msModelField configuration - $addressFields = $this->getModelFieldNames('msOrderAddress'); - foreach ($addressFields as $field) { - if (array_key_exists($field, $params)) { - $oldValue = $address->get($field); - $newValue = $params[$field]; - if ($oldValue != $newValue) { - $changedAddressFields[$field] = ['old' => $oldValue, 'new' => $newValue]; - } - $address->set($field, $newValue); - } - } - - // Handle extra fields for msOrderAddress (stored as real DB columns via Object Extension) - $addressExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrderAddress'); - foreach ($addressExtraFields as $extraFieldKey) { - if (!array_key_exists($extraFieldKey, $params)) { - continue; - } - - $normalized = $this->normalizeExtraFieldValue( - 'MiniShop3\\Model\\msOrderAddress', - $extraFieldKey, - $params[$extraFieldKey] - ); - if (!$normalized['ok']) { - return Response::error( - $normalized['message'], - HttpStatus::UNPROCESSABLE_ENTITY - )->getData(); - } - - $newValue = $normalized['value']; - $oldValue = $address->get($extraFieldKey); - if ($oldValue != $newValue) { - $changedAddressFields[$extraFieldKey] = ['old' => $oldValue, 'new' => $newValue]; - } - $address->set($extraFieldKey, $newValue); - } - - $address->save(); - - // Log address changes - if (!empty($changedAddressFields)) { - $this->getOrderLog()->addEntry( - $id, - msOrderLog::ACTION_ADDRESS, - ['fields' => $changedAddressFields] - ); - } - } - - // Handle status change via OrderStatusService (sends notifications) - $newStatusId = $order->get('status_id'); - if ($oldStatusId != $newStatusId) { - // Revert status to old value - OrderStatusService will change it properly - $order->set('status_id', $oldStatusId); - $order->save(); - - /** @var OrderStatusService $orderStatusService */ - $orderStatusService = $this->modx->services->get('ms3_order_status'); - $result = $orderStatusService->change($order->get('id'), $newStatusId); - - if ($result !== true) { - return Response::error( - is_string($result) ? $result : $this->modx->lexicon('ms3_err_status_change'), - 400 - )->getData(); - } - - // Reload order to get updated data - $order = $this->modx->getObject(msOrder::class, $id); - if (!$order instanceof msOrder) { - return Response::error('Order not found after update', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - } - - // Return the same shape as GET (address-merge, formatted cost, no secret fields) - return Response::success($this->buildOrderPayloadFromModel($order), 'Order updated successfully')->getData(); + return $this->wrapServiceResult($this->getMutationService()->update($params)); } - /** - * Get order products - * GET /api/mgr/orders/{id}/products - * - * @param array $params URL parameters (id) - * @return array Response - */ public function getProducts(array $params = []): array { - $id = (int)($params['id'] ?? 0); - - if (!$id) { - return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); - } - - $c = $this->modx->newQuery(\MiniShop3\Model\msOrderProduct::class); - $c->where(['order_id' => $id]); - $c->leftJoin(\MiniShop3\Model\msProduct::class, 'Product', 'msOrderProduct.product_id = Product.id'); - $c->select($this->modx->getSelectColumns(\MiniShop3\Model\msOrderProduct::class, 'msOrderProduct')); - $c->select(['Product.pagetitle']); - - $products = []; - $collection = $this->modx->getIterator(\MiniShop3\Model\msOrderProduct::class, $c); - foreach ($collection as $product) { - $data = $product->toArray(); - $data['pagetitle'] = $product->get('pagetitle'); - $products[] = $data; - } - - return Response::success(['results' => $products])->getData(); + return $this->wrapServiceResult($this->getProductsService()->getProducts($params)); } - /** - * Add product to order - * POST /api/mgr/orders/{id}/products - * - * @param array $params URL parameters (id) and body data (product_id, count, price, weight, options) - * @return array Response - */ public function addProduct(array $params = []): array { - $orderId = (int)($params['id'] ?? 0); - $productId = (int)($params['product_id'] ?? 0); - - if (!$orderId) { - return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); - } - - if (!$productId) { - return Response::error('Product ID is required', HttpStatus::BAD_REQUEST)->getData(); - } - - // Get the order - $order = $this->modx->getObject(msOrder::class, $orderId); - if (!$order) { - return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Check order status (should not be final) - $status = $this->modx->getObject(\MiniShop3\Model\msOrderStatus::class, $order->get('status_id')); - if ($status && $status->get('final')) { - return Response::error('Cannot add products to finalized order', HttpStatus::BAD_REQUEST)->getData(); - } - - // Get the product - $product = $this->modx->getObject(\MiniShop3\Model\msProduct::class, $productId); - if (!$product) { - return Response::error('Product not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Get product data - $productData = $this->modx->getObject(\MiniShop3\Model\msProductData::class, $productId); - - // Get values from params or product defaults - $count = (int)($params['count'] ?? 1); - if ($count < 1) { - $count = 1; - } - - $price = isset($params['price']) ? (float)$params['price'] : ($productData ? (float)$productData->get('price') : 0); - $weight = isset($params['weight']) ? (float)$params['weight'] : ($productData ? (float)$productData->get('weight') : 0); - $name = $params['name'] ?? $product->get('pagetitle'); - - // Handle options - $options = []; - if (isset($params['options'])) { - if (is_string($params['options'])) { - $decoded = json_decode($params['options'], true); - if (json_last_error() === JSON_ERROR_NONE) { - $options = $decoded; - } - } elseif (is_array($params['options'])) { - $options = $params['options']; - } - } - - // Generate product_key (unique identifier for product variation) - $productKey = md5($productId . json_encode($options)); - - // Create order product record - $orderProduct = $this->modx->newObject(\MiniShop3\Model\msOrderProduct::class); - $orderProduct->set('order_id', $orderId); - $orderProduct->set('product_id', $productId); - $orderProduct->set('product_key', $productKey); - $orderProduct->set('name', $name); - $orderProduct->set('count', $count); - $orderProduct->set('price', $price); - $orderProduct->set('weight', $weight); - $orderProduct->set('cost', $count * $price); - $orderProduct->set('options', !empty($options) ? json_encode($options) : null); - - $eventContext = [ - 'mode' => modSystemEvent::MODE_NEW, - // 'object' — MS2-style alias, 'msOrderProduct' — MS3-style; both point at the same row - 'object' => $orderProduct, - 'msOrderProduct' => $orderProduct, - 'msOrder' => $order, - ]; - - $response = $this->getMs3Utils()->invokeEvent('msOnBeforeCreateOrderProduct', $eventContext); - if (!$response['success']) { - return Response::error($response['message'], HttpStatus::BAD_REQUEST)->getData(); - } - - if (!$orderProduct->save()) { - return Response::error('Failed to add product to order', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - // After-event: row already persisted, a plugin error cannot roll it back. Log and continue - // so the client doesn't get a 4xx for a request that actually succeeded server-side. - $response = $this->getMs3Utils()->invokeEvent('msOnCreateOrderProduct', $eventContext); - if (!$response['success']) { - $this->modx->log( - modX::LOG_LEVEL_WARN, - '[ms3] msOnCreateOrderProduct after-plugin reported error (persistence already done): ' - . $response['message'] - ); - } - - // Log product addition - $this->getOrderLog()->addEntry( - $orderId, - msOrderLog::ACTION_PRODUCTS, - [ - 'operation' => 'add', - 'product_id' => $productId, - 'product_name' => $name, - 'count' => $count, - 'price' => $price, - 'cost' => $count * $price, - ] - ); - - // Recalculate order totals - $this->recalculateOrderTotals($order); - - // Return created product data - $result = $orderProduct->toArray(); - $result['pagetitle'] = $product->get('pagetitle'); - - return Response::success($result, 'Product added to order successfully')->getData(); + return $this->wrapServiceResult($this->getProductsService()->addProduct($params)); } - /** - * Update order product - * PUT /api/mgr/orders/{id}/products/{product_id} - * - * @param array $params URL parameters (id, product_id) and body data - * @return array Response - */ public function updateProduct(array $params = []): array { - $orderId = (int)($params['id'] ?? 0); - $productId = (int)($params['product_id'] ?? 0); - - if (!$orderId || !$productId) { - return Response::error('Order ID and Product ID are required', HttpStatus::BAD_REQUEST)->getData(); - } - - // Find the order product record - $orderProduct = $this->modx->getObject(\MiniShop3\Model\msOrderProduct::class, [ - 'id' => $productId, - 'order_id' => $orderId, - ]); - - if (!$orderProduct) { - return Response::error('Order product not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Get the order to recalculate totals - $order = $this->modx->getObject(msOrder::class, $orderId); - if (!$order) { - return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Store old values for logging - $oldValues = [ - 'count' => $orderProduct->get('count'), - 'price' => $orderProduct->get('price'), - 'weight' => $orderProduct->get('weight'), - 'cost' => $orderProduct->get('cost'), - ]; - - // Allowed fields for update - $allowedFields = ['count', 'price', 'weight', 'options']; - $updated = false; - $changes = []; - - // Per-field null semantics: - // - `options` (JSON): null = explicit clear from frontend (OrderView getOptionsForSave()) - // - `count`/`price`/`weight` (numeric): null skipped — coercing null to 0/1 would - // silently destroy data when a partial payload accidentally carries `null` - // (PrimeVue InputNumber on clear, third-party API, batch scripts). - // Frontend must send explicit 0 / valid number to actually update these. - $nullClearable = ['options']; - - foreach ($allowedFields as $field) { - if (!array_key_exists($field, $params)) { - continue; - } - $value = $params[$field]; - - if ($value === null && !in_array($field, $nullClearable, true)) { - continue; - } - - $oldValue = $orderProduct->get($field); - - switch ($field) { - case 'count': - $value = max(1, (int)$value); - break; - case 'price': - case 'weight': - $value = max(0, (float)$value); - break; - case 'options': - // null passes through (clear), arrays get encoded as JSON - if (is_array($value)) { - $value = json_encode($value, JSON_UNESCAPED_UNICODE); - } - break; - } - - // Track changes for logging - if ($oldValue != $value) { - $changes[$field] = ['old' => $oldValue, 'new' => $value]; - } - - $orderProduct->set($field, $value); - $updated = true; - } - - if (!$updated) { - return Response::error('No fields to update', HttpStatus::BAD_REQUEST)->getData(); - } - - // Recalculate cost - $count = $orderProduct->get('count'); - $price = $orderProduct->get('price'); - $newCost = $count * $price; - $orderProduct->set('cost', $newCost); - - // Add cost change if it changed - if ($oldValues['cost'] != $newCost) { - $changes['cost'] = ['old' => $oldValues['cost'], 'new' => $newCost]; - } - - $eventContext = [ - 'mode' => modSystemEvent::MODE_UPD, - // 'object' — MS2-style alias, 'msOrderProduct' — MS3-style; both point at the same row - 'object' => $orderProduct, - 'msOrderProduct' => $orderProduct, - 'msOrder' => $order, - ]; - - $response = $this->getMs3Utils()->invokeEvent('msOnBeforeUpdateOrderProduct', $eventContext); - if (!$response['success']) { - return Response::error($response['message'], HttpStatus::BAD_REQUEST)->getData(); - } - - if (!$orderProduct->save()) { - return Response::error('Failed to update order product', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - // After-event: row already persisted, a plugin error cannot roll it back. Log and continue - // so the client doesn't get a 4xx for a request that actually succeeded server-side. - $response = $this->getMs3Utils()->invokeEvent('msOnUpdateOrderProduct', $eventContext); - if (!$response['success']) { - $this->modx->log( - modX::LOG_LEVEL_WARN, - '[ms3] msOnUpdateOrderProduct after-plugin reported error (persistence already done): ' - . $response['message'] - ); - } - - // Log product update if there were changes - if (!empty($changes)) { - $this->getOrderLog()->addEntry( - $orderId, - msOrderLog::ACTION_PRODUCTS, - [ - 'operation' => 'update', - 'product_id' => $orderProduct->get('product_id'), - 'product_name' => $orderProduct->get('name'), - 'changes' => $changes, - ] - ); - } - - // Recalculate order totals - $this->recalculateOrderTotals($order); - - return Response::success( - $orderProduct->toArray(), - 'Order product updated successfully' - )->getData(); + return $this->wrapServiceResult($this->getProductsService()->updateProduct($params)); } - /** - * Delete order product - * DELETE /api/mgr/orders/{id}/products/{product_id} - * - * @param array $params URL parameters (id, product_id) - * @return array Response - */ public function deleteProduct(array $params = []): array { - $orderId = (int)($params['id'] ?? 0); - $productId = (int)($params['product_id'] ?? 0); - - if (!$orderId || !$productId) { - return Response::error('Order ID and Product ID are required', HttpStatus::BAD_REQUEST)->getData(); - } - - // Find the order product record - $orderProduct = $this->modx->getObject(\MiniShop3\Model\msOrderProduct::class, [ - 'id' => $productId, - 'order_id' => $orderId, - ]); - - if (!$orderProduct) { - return Response::error('Order product not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Get the order to recalculate totals - $order = $this->modx->getObject(msOrder::class, $orderId); - if (!$order) { - return Response::error('Order not found', HttpStatus::NOT_FOUND)->getData(); - } - - // Check if this is the last product - $productCount = $this->modx->getCount(\MiniShop3\Model\msOrderProduct::class, ['order_id' => $orderId]); - if ($productCount <= 1) { - return Response::error('Cannot delete the last product from order', HttpStatus::BAD_REQUEST)->getData(); - } - - // Store data for logging before removal - $productData = [ - 'product_id' => $orderProduct->get('product_id'), - 'product_name' => $orderProduct->get('name'), - 'count' => $orderProduct->get('count'), - 'price' => $orderProduct->get('price'), - 'cost' => $orderProduct->get('cost'), - ]; - - $eventContext = [ - 'id' => $orderProduct->get('id'), - // 'object' — MS2-style alias, 'msOrderProduct' — MS3-style; both point at the same row - 'object' => $orderProduct, - 'msOrderProduct' => $orderProduct, - 'msOrder' => $order, - ]; - - $response = $this->getMs3Utils()->invokeEvent('msOnBeforeRemoveOrderProduct', $eventContext); - if (!$response['success']) { - return Response::error($response['message'], HttpStatus::BAD_REQUEST)->getData(); - } - - if (!$orderProduct->remove()) { - return Response::error('Failed to delete order product', HttpStatus::INTERNAL_SERVER_ERROR)->getData(); - } - - // After-event: row already removed, a plugin error cannot roll it back. Log and continue - // so the client doesn't get a 4xx for a request that actually succeeded server-side. - $response = $this->getMs3Utils()->invokeEvent('msOnRemoveOrderProduct', $eventContext); - if (!$response['success']) { - $this->modx->log( - modX::LOG_LEVEL_WARN, - '[ms3] msOnRemoveOrderProduct after-plugin reported error (persistence already done): ' - . $response['message'] - ); - } - - // Log product removal - $this->getOrderLog()->addEntry( - $orderId, - msOrderLog::ACTION_PRODUCTS, - [ - 'operation' => 'remove', - 'product_id' => $productData['product_id'], - 'product_name' => $productData['product_name'], - 'count' => $productData['count'], - 'price' => $productData['price'], - 'cost' => $productData['cost'], - ] - ); - - // Recalculate order totals - $this->recalculateOrderTotals($order); - - return Response::success(null, 'Order product deleted successfully')->getData(); - } - - /** - * Recalculate order totals (cart_cost, weight, cost) - * - * @param msOrder $order Order object - */ - protected function recalculateOrderTotals(msOrder $order): void - { - $products = $this->modx->getIterator(\MiniShop3\Model\msOrderProduct::class, [ - 'order_id' => $order->get('id'), - ]); - $totals = OrderService::aggregateProductsTotals($products); - $cartCost = $totals['cart_cost']; - $weight = $totals['weight']; - - $order->set('cart_cost', $cartCost); - $order->set('weight', $weight); - // Recalculate total cost (cart + delivery; payment deltas are reflected in cost when persisted elsewhere) - /** @var OrderService $orderService */ - $orderService = $this->modx->services->get('ms3_order_service'); - $deliveryCost = (float) $order->get('delivery_cost'); - $order->set('cost', $orderService->clampComputedTotal($order, $cartCost, $deliveryCost, 0.0)); - - $order->save(); + return $this->wrapServiceResult($this->getProductsService()->deleteProduct($params)); } /** * Get order logs (history) * GET /api/mgr/orders/{id}/logs - * - * @param array $params URL parameters (id, visible_only) - * @return array Response */ public function getLogs(array $params = []): array { $id = (int)($params['id'] ?? 0); - if (!$id) { return Response::error('Order ID is required', HttpStatus::BAD_REQUEST)->getData(); } - $c = $this->modx->newQuery(\MiniShop3\Model\msOrderLog::class); + $c = $this->modx->newQuery(msOrderLog::class); $c->where(['order_id' => $id]); // Filter by visibility if requested (for customer-facing views) @@ -1418,7 +256,7 @@ public function getLogs(array $params = []): array $c->sortby('timestamp', 'DESC'); $logs = []; - $collection = $this->modx->getIterator(\MiniShop3\Model\msOrderLog::class, $c); + $collection = $this->modx->getIterator(msOrderLog::class, $c); foreach ($collection as $log) { $data = $log->toArray(); @@ -1439,7 +277,7 @@ public function getLogs(array $params = []): array // Add user name if ($data['user_id']) { - $user = $this->modx->getObject(\MODX\Revolution\modUser::class, $data['user_id']); + $user = $this->modx->getObject(modUser::class, $data['user_id']); $data['user_name'] = $user ? $user->get('username') : 'Unknown'; } @@ -1449,356 +287,118 @@ public function getLogs(array $params = []): array return Response::success(['results' => $logs])->getData(); } - /** - * Log status change - * - * @param msOrder $order Order object - * @param int $oldStatusId Old status ID - * @param int $newStatusId New status ID - */ - protected function logStatusChange(msOrder $order, int $oldStatusId, int $newStatusId): void - { - $oldStatus = $this->modx->getObject(msOrderStatus::class, $oldStatusId); - $newStatus = $this->modx->getObject(msOrderStatus::class, $newStatusId); - - $this->getOrderLog()->addEntry( - $order->get('id'), - msOrderLog::ACTION_STATUS, - [ - 'old_status_id' => $oldStatusId, - 'new_status_id' => $newStatusId, - 'old_status_name' => $oldStatus ? $oldStatus->get('name') : (string)$oldStatusId, - 'new_status_name' => $newStatus ? $newStatus->get('name') : (string)$newStatusId, - ] - ); - } - - /** - * Get filters configuration - * GET /api/mgr/orders/filters - * - * @param array $params URL parameters - * @return array Response with filters config - */ public function getFilters(array $params = []): array { - $filterManager = new FilterConfigManager($this->modx); - $filters = $filterManager->getFilters('orders', true); - - // Sort by position - uasort($filters, fn($a, $b) => ($a['position'] ?? 100) <=> ($b['position'] ?? 100)); - - return Response::success(['filters' => $filters])->getData(); + return Response::success($this->getListService()->getFilters())->getData(); } /** - * Get orders statistics based on current filters - * - * Statistics are calculated only for orders with statuses - * specified in ms3_status_for_stat setting (e.g. "2,3" for paid/completed). - * - * @param array $params Filter parameters (same as getList) - * @return array Statistics data + * @param array{success: bool, message?: string, data?: array, status?: int} $result */ - protected function getOrdersStats(array $params = []): array + protected function wrapServiceResult(array $result): array { - $c = $this->modx->newQuery(msOrder::class); - - if ($this->hasAddressFilter($params)) { - $c->leftJoin(msOrderAddress::class, 'Address', '`Address`.order_id = msOrder.id'); - } + $success = !empty($result['success']); + $message = (string)($result['message'] ?? ''); + $data = array_key_exists('data', $result) ? $result['data'] : []; + $status = (int)($result['status'] ?? HttpStatus::BAD_REQUEST); - // Filter by statuses for statistics (ms3_status_for_stat) - // Only count orders with these statuses (e.g. paid, completed) - $statusForStat = $this->modx->getOption('ms3_status_for_stat', null, '2,3'); - if (!empty($statusForStat)) { - $statuses = array_map('intval', array_filter(explode(',', $statusForStat))); - if (!empty($statuses)) { - $c->where(['status_id:IN' => $statuses]); - } + if ($success) { + return Response::success($data, $message)->getData(); } - // Apply filter_ prefixed params - foreach ($params as $key => $value) { - if (str_starts_with($key, 'filter_') && $value !== '' && $value !== null) { - $fieldName = substr($key, 7); - $this->applyFilter($c, $fieldName, $value); - } + $errorPayload = []; + if (isset($data['fields']) && is_array($data['fields'])) { + $errorPayload = $data['fields']; + } elseif (!empty($data)) { + $errorPayload = $data; } - $this->applyDirectFilters($c, $params); - - // Calculate sum and count - $c->select('SUM(msOrder.cost) as sum, COUNT(msOrder.id) as total'); - $c->prepare(); - $c->stmt->execute(); - $data = $c->stmt->fetch(\PDO::FETCH_ASSOC); - - return [ - 'month_sum' => number_format(round($data['sum'] ?? 0), 0, '.', ' '), - 'month_total' => number_format($data['total'] ?? 0, 0, '.', ' '), - ]; + return Response::error($message, $status, $errorPayload)->getData(); } - protected function hasAddressFilter(array $params): bool + protected function getPresenter(): ManagerOrderPresenter { - foreach (self::ADDRESS_FILTER_KEYS as $fieldName) { - $value = $params['filter_' . $fieldName] ?? null; - if ($value !== null && $value !== '') { - return true; + if ($this->presenter === null) { + if ($this->modx->services->has('ms3_manager_order_presenter')) { + $this->presenter = $this->modx->services->get('ms3_manager_order_presenter'); + } else { + $this->presenter = new ManagerOrderPresenter($this->modx); } } - return false; + return $this->presenter; } - /** - * Whether draft orders should be included in the manager orders list query. - * - * When `show_drafts` is present in request params it overrides `ms3_order_show_drafts`. - * The Vue orders grid always sends this flag (initialized from ms3.config.order_show_drafts). - */ - protected function shouldShowDrafts(array $params): bool + protected function getListService(): ManagerOrderListService { - $default = (bool) $this->modx->getOption('ms3_order_show_drafts', null, false); - - if (!array_key_exists('show_drafts', $params)) { - return $default; - } - - $value = $params['show_drafts']; - if ($value === '' || $value === null) { - return $default; - } - - return filter_var($value, FILTER_VALIDATE_BOOLEAN); - } - - /** - * Exclude draft status from getList query unless drafts are explicitly shown. - * - * @param \xPDO\Om\xPDOQuery $c Query object - */ - protected function applyDraftVisibilityFilter(\xPDO\Om\xPDOQuery $c, array $params): void - { - if ($this->shouldShowDrafts($params)) { - return; - } - - $statusDrafts = (int) $this->modx->getOption('ms3_status_draft', null, 1) ?: 1; - $c->where(['status_id:!=' => $statusDrafts]); - } - - /** - * Apply filter to query - * - * @param \xPDO\Om\xPDOQuery $c Query object - * @param string $fieldName Field name - * @param mixed $value Filter value - */ - protected function applyFilter($c, string $fieldName, $value): void - { - ManagerListFilterPolicy::applyOrderFilter($c, $fieldName, $value); - } - - /** - * Apply direct (unprefixed) filter params to an orders query. - * - * @param \xPDO\Om\xPDOQuery $c Query object - * @param array $params Request parameters - */ - protected function applyDirectFilters($c, array $params): void - { - foreach (self::DIRECT_FILTER_FIELD_MAP as $paramKey => $fieldName) { - $value = $params[$paramKey] ?? null; - if ($value === null || $value === '') { - continue; - } - - if (in_array($paramKey, self::DIRECT_FILTER_INT_KEYS, true)) { - $value = (int)$value; + if ($this->listService === null) { + if ($this->modx->services->has('ms3_manager_order_list')) { + $this->listService = $this->modx->services->get('ms3_manager_order_list'); + } else { + $this->listService = new ManagerOrderListService($this->modx, $this->getPresenter()); } - - $c->where([$fieldName => $value]); - } - - $dateFrom = $params['createdon_from'] ?? $params['date_start'] ?? null; - if ($dateFrom) { - $c->where([ - 'msOrder.createdon:>=' => date('Y-m-d 00:00:00', strtotime($dateFrom)), - ]); } - $dateTo = $params['createdon_to'] ?? $params['date_end'] ?? null; - if ($dateTo) { - $c->where([ - 'msOrder.createdon:<=' => date('Y-m-d 23:59:59', strtotime($dateTo)), - ]); - } - } - - /** - * Map sort field to database column - * - * @param string $sort Sort field name - * @return string Database column - */ - protected function mapSortField(string $sort): string - { - // Model fields mapping - $mapping = [ - 'id' => 'msOrder.id', - 'num' => 'msOrder.num', - 'cost' => 'msOrder.cost', - 'cart_cost' => 'msOrder.cart_cost', - 'delivery_cost' => 'msOrder.delivery_cost', - 'weight' => 'msOrder.weight', - 'createdon' => 'msOrder.createdon', - 'updatedon' => 'msOrder.updatedon', - 'status_id' => 'msOrder.status_id', - 'delivery_id' => 'msOrder.delivery_id', - 'payment_id' => 'msOrder.payment_id', - 'context' => 'msOrder.context', - ]; - - // For relation fields (status_name, delivery_name, etc.) - sort by the alias - // This works because we SELECT them AS `field_name` - return $mapping[$sort] ?? 'msOrder.id'; + return $this->listService; } - /** - * Format order data for API response - * - * @param array $data Order data - * @return array Formatted data - */ - protected function formatOrder(array $data): array + protected function getMutationService(): ManagerOrderMutationService { - $ms3 = $this->modx->services->get('ms3'); - - if (!empty($data['status_name']) && str_starts_with($data['status_name'], 'ms3_')) { - $translated = $this->modx->lexicon($data['status_name']); - if ($translated !== $data['status_name']) { - $data['status_name'] = $translated; - } - } - - if (!empty($data['delivery_name']) && str_starts_with($data['delivery_name'], 'ms3_')) { - $translated = $this->modx->lexicon($data['delivery_name']); - if ($translated !== $data['delivery_name']) { - $data['delivery_name'] = $translated; - } - } - - if (!empty($data['payment_name']) && str_starts_with($data['payment_name'], 'ms3_')) { - $translated = $this->modx->lexicon($data['payment_name']); - if ($translated !== $data['payment_name']) { - $data['payment_name'] = $translated; - } - } - - $data['customer'] = trim(implode(' ', [ - $data['first_name'] ?? '', - $data['last_name'] ?? '', - ])); - - if ($ms3) { - if (isset($data['cost'])) { - $data['cost_formatted'] = $ms3->format->price($data['cost']); - } - if (isset($data['cart_cost'])) { - $data['cart_cost_formatted'] = $ms3->format->price($data['cart_cost']); - } - if (isset($data['delivery_cost'])) { - $data['delivery_cost_formatted'] = $ms3->format->price($data['delivery_cost']); - } - if (isset($data['weight'])) { - $data['weight_formatted'] = $ms3->format->weight($data['weight']); + if ($this->mutationService === null) { + if ($this->modx->services->has('ms3_manager_order_mutation')) { + $this->mutationService = $this->modx->services->get('ms3_manager_order_mutation'); + } else { + $this->mutationService = new ManagerOrderMutationService( + $this->modx, + $this->getPresenter(), + $this->getOrderLog() + ); } } - foreach (self::HIDDEN_ORDER_FIELDS as $field) { - unset($data[$field]); - } - - return $data; + return $this->mutationService; } - /** - * @return array{ok: bool, value?: mixed, message?: string} - */ - protected function normalizeExtraFieldValue(string $modelClass, string $fieldKey, mixed $value): array + protected function getProductsService(): ManagerOrderProductsService { - /** @var msExtraField|null $definition */ - $definition = $this->modx->getObject(msExtraField::class, [ - 'class' => $modelClass, - 'key' => $fieldKey, - 'active' => true, - ]); - - if (!$definition || $definition->get('xtype') !== RepeaterFieldService::XTYPE) { - return ['ok' => true, 'value' => $value]; + if ($this->productsService === null) { + if ($this->modx->services->has('ms3_manager_order_products')) { + $this->productsService = $this->modx->services->get('ms3_manager_order_products'); + } else { + $this->productsService = new ManagerOrderProductsService($this->modx, $this->getOrderLog()); + } } - /** @var RepeaterFieldService $repeaterService */ - $repeaterService = $this->modx->services->get('ms3_repeater_field'); - $config = $repeaterService->parseConfig($definition->get('repeater_config')); - - try { - return ['ok' => true, 'value' => $repeaterService->processValue($value, $config)]; - } catch (\InvalidArgumentException $e) { - $this->modx->lexicon->load('minishop3:default'); - - return [ - 'ok' => false, - 'message' => $this->modx->lexicon('ms3_repeater_validation_error', [ - 'field' => $fieldKey, - 'error' => $e->getMessage(), - ]), - ]; - } + return $this->productsService; } - /** - * Get extra field keys for a specific model class - * - * @param string $modelClass Model class name (msOrder, msOrderAddress, etc.) - * @return array Array of extra field keys - */ - protected function getExtraFieldKeys(string $modelClass): array + protected function getOrderCostRecalculator(): ManagerOrderCostRecalculator { - $keys = []; - - $query = $this->modx->newQuery(msExtraField::class); - $query->where([ - 'class' => $modelClass, - 'active' => true, - ]); - - foreach ($this->modx->getIterator(msExtraField::class, $query) as $field) { - $keys[] = $field->get('key'); + if ($this->costRecalculator === null) { + if ($this->modx->services->has('ms3_manager_order_cost_recalculator')) { + $this->costRecalculator = $this->modx->services->get('ms3_manager_order_cost_recalculator'); + } else { + /** @var MiniShop3 $ms3 */ + $ms3 = $this->modx->services->get('ms3'); + $this->costRecalculator = new ManagerOrderCostRecalculator($this->modx, $ms3); + } } - return $keys; + return $this->costRecalculator; } - /** - * Get field names from msModelField configuration - * - * @param string $model Model name (msOrder, msOrderAddress) - * @return array Array of field names - */ - protected function getModelFieldNames(string $model): array + protected function getOrderLog(): OrderLogService { - $names = []; - - $query = $this->modx->newQuery(msModelField::class); - $query->where(['model' => $model]); - - foreach ($this->modx->getIterator(msModelField::class, $query) as $field) { - $names[] = $field->get('name'); + if ($this->orderLog === null) { + if ($this->modx->services->has('ms3_order_log')) { + $this->orderLog = $this->modx->services->get('ms3_order_log'); + } else { + /** @var MiniShop3 $ms3 */ + $ms3 = $this->modx->services->get('ms3'); + $this->orderLog = new OrderLogService($this->modx, $ms3); + } } - return $names; + return $this->orderLog; } } diff --git a/core/components/minishop3/src/ServiceRegistry.php b/core/components/minishop3/src/ServiceRegistry.php index 04980615..81e52d7a 100644 --- a/core/components/minishop3/src/ServiceRegistry.php +++ b/core/components/minishop3/src/ServiceRegistry.php @@ -63,6 +63,10 @@ class ServiceRegistry 'class' => \MiniShop3\Services\ExtraFields\RepeaterFieldService::class, 'interface' => null, ], + 'ms3_extra_fields' => [ + 'class' => \MiniShop3\Services\ExtraFieldsService::class, + 'interface' => null, + ], 'ms3_product_image' => [ 'class' => \MiniShop3\Services\Product\ProductImageService::class, 'interface' => null, @@ -125,6 +129,26 @@ class ServiceRegistry 'class' => \MiniShop3\Services\Order\OrderFinalizeService::class, 'interface' => null, ], + 'ms3_manager_order_presenter' => [ + 'class' => \MiniShop3\Services\Order\ManagerOrderPresenter::class, + 'interface' => null, + ], + 'ms3_manager_order_list' => [ + 'class' => \MiniShop3\Services\Order\ManagerOrderListService::class, + 'interface' => null, + ], + 'ms3_manager_order_mutation' => [ + 'class' => \MiniShop3\Services\Order\ManagerOrderMutationService::class, + 'interface' => null, + ], + 'ms3_manager_order_products' => [ + 'class' => \MiniShop3\Services\Order\ManagerOrderProductsService::class, + 'interface' => null, + ], + 'ms3_manager_order_cost_recalculator' => [ + 'class' => \MiniShop3\Services\Order\ManagerOrderCostRecalculator::class, + 'interface' => null, + ], // Cart services 'ms3_cart_item_manager' => [ 'class' => \MiniShop3\Services\Cart\CartItemManager::class, @@ -444,131 +468,25 @@ protected function registerService(string $serviceKey, array $config): bool $validatedClass = $this->validateClass($className, $fallbackClass, $requiredInterface); - $modx = $this->modx; + $factories = ServiceRegistryFactories::map(); + if (!isset($factories[$serviceKey])) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + "[MiniShop3 ServiceRegistry] No factory registered for service '{$serviceKey}'" + ); - // Controllers requiring only MiniShop3 instance: __construct(MiniShop3 $ms3) - $controllersWithMs3Only = ['ms3_cart', 'ms3_order', 'ms3_customer']; - - // Services requiring both modX and MiniShop3: __construct(modX $modx, MiniShop3 $ms3) - $servicesWithModxAndMs3 = [ - 'ms3_order_draft_manager', - 'ms3_order_cost_calculator', - 'ms3_order_user_resolver', - 'ms3_order_log', - 'ms3_cart_item_manager', - 'ms3_customer_address_manager', - ]; - - // Services with complex dependencies (resolved via DI) - $servicesWithDependencies = [ - 'ms3_order_field_manager', - 'ms3_order_address_manager', - 'ms3_order_submit_handler', - 'ms3_order_status', - 'ms3_order_finalize', - ]; - - if (in_array($serviceKey, $controllersWithMs3Only)) { - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - return new $validatedClass($ms3); - }); - } elseif (in_array($serviceKey, $servicesWithModxAndMs3)) { - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - return new $validatedClass($modx, $ms3); - }); - } elseif (in_array($serviceKey, $servicesWithDependencies)) { - // Services with dependencies - resolve them from DI - $this->registerServiceWithDependencies($serviceKey, $validatedClass); - } else { - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - return new $validatedClass($modx); - }); + return false; } - return true; - } - - /** - * Register service with complex dependencies - * - * Dependencies are resolved from DI container (lazy loading). - * This allows overriding any dependency via config. - * - * @param string $serviceKey Service key - * @param string $validatedClass Validated class name - * @return void - */ - protected function registerServiceWithDependencies(string $serviceKey, string $validatedClass): void - { + $factory = $factories[$serviceKey]; $modx = $this->modx; + $services = $this->modx->services; - switch ($serviceKey) { - case 'ms3_order_field_manager': - // OrderFieldManager(modX, MiniShop3, OrderDraftManager) - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - $draftManager = $modx->services->get('ms3_order_draft_manager'); - return new $validatedClass($modx, $ms3, $draftManager); - }); - break; - - case 'ms3_order_address_manager': - // OrderAddressManager(modX, MiniShop3, OrderDraftManager, OrderFieldManager) - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - $draftManager = $modx->services->get('ms3_order_draft_manager'); - $fieldManager = $modx->services->get('ms3_order_field_manager'); - return new $validatedClass($modx, $ms3, $draftManager, $fieldManager); - }); - break; - - case 'ms3_order_submit_handler': - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - $draftManager = $modx->services->get('ms3_order_draft_manager'); - $costCalculator = $modx->services->get('ms3_order_cost_calculator'); - $fieldManager = $modx->services->get('ms3_order_field_manager'); - $addressManager = $modx->services->get('ms3_order_address_manager'); - $userResolver = $modx->services->get('ms3_order_user_resolver'); - $numberGenerator = $modx->services->get('ms3_order_number_generator'); - return new $validatedClass( - $modx, - $ms3, - $draftManager, - $costCalculator, - $fieldManager, - $addressManager, - $userResolver, - $numberGenerator - ); - }); - break; - - case 'ms3_order_finalize': - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - $numberGenerator = $modx->services->get('ms3_order_number_generator'); - return new $validatedClass($modx, $ms3, $numberGenerator); - }); - break; - - case 'ms3_order_status': - // OrderStatusService(modX, MiniShop3, OrderLogService) - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - $ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class); - $orderLog = $modx->services->get('ms3_order_log'); - return new $validatedClass($modx, $ms3, $orderLog); - }); - break; - - default: - // Fallback: create with modX only - $this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) { - return new $validatedClass($modx); - }); - } + $this->modx->services->add($serviceKey, function () use ($factory, $validatedClass, $modx, $services) { + return $factory($modx, $services, $validatedClass); + }); + + return true; } /** @@ -608,7 +526,8 @@ protected function validateClass( if (!in_array($requiredInterface, $interfaces ?: [])) { $this->modx->log( modX::LOG_LEVEL_ERROR, - "[MiniShop3 ServiceRegistry] Class '{$className}' must implement {$requiredInterface}, using fallback" + "[MiniShop3 ServiceRegistry] Class '{$className}' must implement {$requiredInterface}, " + . 'using fallback' ); return $fallbackClass; } diff --git a/core/components/minishop3/src/ServiceRegistryFactories.php b/core/components/minishop3/src/ServiceRegistryFactories.php new file mode 100644 index 00000000..5b5a359c --- /dev/null +++ b/core/components/minishop3/src/ServiceRegistryFactories.php @@ -0,0 +1,156 @@ + callable(modX $modx, object $services, string $class): object + * + * @internal + */ +class ServiceRegistryFactories +{ + /** + * @return array + */ + public static function map(): array + { + $modxOnly = static fn (): callable => static function (modX $modx, object $services, string $class): object { + return new $class($modx); + }; + + $ms3Only = static fn (): callable => static function (modX $modx, object $services, string $class): object { + return new $class(self::ms3($modx)); + }; + + $modxAndMs3 = static fn (): callable => static function (modX $modx, object $services, string $class): object { + return new $class($modx, self::ms3($modx)); + }; + + return [ + 'ms3_field_config_manager' => $modxOnly(), + 'ms3_config_service' => $modxOnly(), + 'ms3_product_service' => $modxOnly(), + 'ms3_product_data_service' => $modxOnly(), + 'ms3_repeater_field' => $modxOnly(), + 'ms3_extra_fields' => $modxOnly(), + 'ms3_product_image' => $modxOnly(), + 'ms3_vendor_service' => $modxOnly(), + 'ms3_delivery_service' => $modxOnly(), + 'ms3_payment_service' => $modxOnly(), + 'ms3_order_service' => $modxOnly(), + 'ms3_order_number_generator' => $modxOnly(), + 'ms3_manager_order_presenter' => $modxOnly(), + 'ms3_token_service' => $modxOnly(), + 'ms3_category_service' => $modxOnly(), + 'ms3_category_option_service' => $modxOnly(), + 'ms3_image' => $modxOnly(), + 'ms3_option_service' => $modxOnly(), + 'ms3_auth_manager' => $modxOnly(), + 'ms3_register_service' => $modxOnly(), + 'ms3_email_verification_service' => $modxOnly(), + 'ms3_sms_verification_service' => $modxOnly(), + 'ms3_rate_limiter' => $modxOnly(), + 'ms3_grid_config' => $modxOnly(), + 'ms3_category_products_list' => $modxOnly(), + 'ms3_category_product_scope' => $modxOnly(), + 'ms3_category_tree' => $modxOnly(), + 'ms3_filter_config' => $modxOnly(), + 'ms3_notifications' => $modxOnly(), + 'ms3_notification_config' => $modxOnly(), + 'ms3_customer_duplicate_checker' => $modxOnly(), + 'ms3_customer_factory' => $modxOnly(), + + 'ms3_cart' => $ms3Only(), + 'ms3_order' => $ms3Only(), + 'ms3_customer' => $ms3Only(), + + 'ms3_order_draft_manager' => $modxAndMs3(), + 'ms3_order_cost_calculator' => $modxAndMs3(), + 'ms3_order_user_resolver' => $modxAndMs3(), + 'ms3_order_log' => $modxAndMs3(), + 'ms3_manager_order_cost_recalculator' => $modxAndMs3(), + 'ms3_cart_item_manager' => $modxAndMs3(), + 'ms3_customer_address_manager' => $modxAndMs3(), + + 'ms3_order_field_manager' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + self::ms3($modx), + $services->get('ms3_order_draft_manager') + ); + }, + + 'ms3_order_address_manager' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + self::ms3($modx), + $services->get('ms3_order_draft_manager'), + $services->get('ms3_order_field_manager') + ); + }, + + 'ms3_order_submit_handler' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + self::ms3($modx), + $services->get('ms3_order_draft_manager'), + $services->get('ms3_order_cost_calculator'), + $services->get('ms3_order_field_manager'), + $services->get('ms3_order_address_manager'), + $services->get('ms3_order_user_resolver'), + $services->get('ms3_order_number_generator') + ); + }, + + 'ms3_order_finalize' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + self::ms3($modx), + $services->get('ms3_order_number_generator') + ); + }, + + 'ms3_order_status' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + self::ms3($modx), + $services->get('ms3_order_log') + ); + }, + + 'ms3_manager_order_list' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + $services->get('ms3_manager_order_presenter') + ); + }, + + 'ms3_manager_order_mutation' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + $services->get('ms3_manager_order_presenter'), + $services->get('ms3_order_log') + ); + }, + + 'ms3_manager_order_products' => static function (modX $modx, object $services, string $class): object { + return new $class( + $modx, + $services->get('ms3_order_log') + ); + }, + ]; + } + + private static function ms3(modX $modx): MiniShop3 + { + /** @var MiniShop3 $ms3 */ + $ms3 = $modx->services->get('ms3'); + + return $ms3; + } +} diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderListService.php b/core/components/minishop3/src/Services/Order/ManagerOrderListService.php new file mode 100644 index 00000000..21305ff5 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/ManagerOrderListService.php @@ -0,0 +1,355 @@ + msOrder column for direct (unprefixed) filter params. */ + public const DIRECT_FILTER_FIELD_MAP = [ + 'status_id' => 'status_id', + 'delivery_id' => 'delivery_id', + 'payment_id' => 'payment_id', + 'context_key' => 'context', + ]; + + public const ADDRESS_FILTER_KEYS = [ + 'customer', + 'email', + 'phone', + ]; + + protected modX $modx; + protected ManagerOrderPresenter $presenter; + + public function __construct(modX $modx, ?ManagerOrderPresenter $presenter = null) + { + $this->modx = $modx; + $this->presenter = $presenter ?? new ManagerOrderPresenter($modx); + } + + public static function getDirectFilterKeys(): array + { + return self::DIRECT_FILTER_KEYS; + } + + /** + * @param array $params + * @return array{results: array>, total: int, stats: array} + */ + public function getList(array $params): array + { + $start = (int)($params['start'] ?? 0); + $limit = (int)($params['limit'] ?? 20); + $query = trim((string)($params['query'] ?? '')); + $sort = (string)($params['sort'] ?? 'id'); + $dir = strtoupper((string)($params['dir'] ?? 'DESC')); + + $gridConfig = $this->modx->services->get('ms3_grid_config'); + // Get ALL fields including hidden (for relation JOINs) + $gridFields = $gridConfig ? $gridConfig->getGridConfig('orders', true) : []; + + $c = $this->modx->newQuery(msOrder::class); + + // Address JOIN is always needed for search and customer info + $c->leftJoin(msOrderAddress::class, 'Address', '`Address`.order_id = msOrder.id'); + + // Dynamic JOINs from relation fields in grid config + $relationGroups = $gridConfig ? $gridConfig->extractRelationFields($gridFields) : []; + foreach ($relationGroups as $group) { + if (!empty($group['modelClass'])) { + $c->leftJoin( + $group['modelClass'], + $group['alias'], + "`{$group['alias']}`.id = msOrder.{$group['foreignKey']}" + ); + } + } + + $this->applyDraftVisibilityFilter($c, $params); + + if ($query !== '') { + if (is_numeric($query)) { + $c->andCondition([ + 'id' => $query, + 'OR:Address.phone:LIKE' => "%{$query}%", + ]); + } else { + $c->where([ + 'num:LIKE' => "{$query}%", + 'OR:order_comment:LIKE' => "%{$query}%", + 'OR:Address.comment:LIKE' => "%{$query}%", + 'OR:Address.first_name:LIKE' => "%{$query}%", + 'OR:Address.last_name:LIKE' => "%{$query}%", + 'OR:Address.email:LIKE' => "%{$query}%", + 'OR:Address.phone:LIKE' => "%{$query}%", + ]); + } + } + + foreach ($params as $key => $value) { + if (str_starts_with((string)$key, 'filter_') && $value !== '' && $value !== null) { + $fieldName = substr((string)$key, 7); + $this->applyFilter($c, $fieldName, $value); + } + } + + $this->applyDirectFilters($c, $params); + + // Legacy params for backward compatibility + if ($customer = ($params['customer'] ?? null)) { + $c->where(['customer_id' => (int)$customer]); + } + if ($context = ($params['context'] ?? null)) { + $c->where(['context' => $context]); + } + + $countQuery = clone $c; + $countQuery->select('COUNT(DISTINCT msOrder.id)'); + $countQuery->prepare(); + $countQuery->stmt->execute(); + $total = (int)$countQuery->stmt->fetchColumn(); + + // Build SELECT: base model fields + address fields + dynamic relation fields + $selectParts = [ + $this->modx->getSelectColumns(msOrder::class, 'msOrder'), + '`Address`.first_name', '`Address`.last_name', '`Address`.phone', '`Address`.email', + ]; + + // Add SELECT for relation fields + foreach ($relationGroups as $group) { + foreach ($group['fields'] as $fieldDef) { + $selectParts[] = "`{$group['alias']}`.{$fieldDef['displayField']} as `{$fieldDef['name']}`"; + } + } + + $c->select(implode(', ', $selectParts)); + + $sortField = $this->mapSortField($sort); + $c->sortby($sortField, $dir); + + if ($limit > 0) { + $c->limit($limit, $start); + } + + $c->prepare(); + $rows = $c->stmt->execute() ? $c->stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + + $results = []; + foreach ($rows as $row) { + $results[] = $this->presenter->formatOrder($row); + } + + return [ + 'results' => $results, + 'total' => $total, + 'stats' => $this->getOrdersStats($params), + ]; + } + + /** + * @return array{filters: array} + */ + public function getFilters(): array + { + $filterManager = new FilterConfigManager($this->modx); + $filters = $filterManager->getFilters('orders', true); + + // Sort by position + uasort($filters, fn($a, $b) => ($a['position'] ?? 100) <=> ($b['position'] ?? 100)); + + return ['filters' => $filters]; + } + + /** + * Statistics are calculated only for orders with statuses + * specified in ms3_status_for_stat setting (e.g. "2,3" for paid/completed). + * + * @param array $params + * @return array{month_sum: string, month_total: string} + */ + protected function getOrdersStats(array $params = []): array + { + $c = $this->modx->newQuery(msOrder::class); + + if ($this->hasAddressFilter($params)) { + $c->leftJoin(msOrderAddress::class, 'Address', '`Address`.order_id = msOrder.id'); + } + + // Filter by statuses for statistics (ms3_status_for_stat) + // Only count orders with these statuses (e.g. paid, completed) + $statusForStat = $this->modx->getOption('ms3_status_for_stat', null, '2,3'); + if (!empty($statusForStat)) { + $statuses = array_map('intval', array_filter(explode(',', (string)$statusForStat))); + if (!empty($statuses)) { + $c->where(['status_id:IN' => $statuses]); + } + } + + // Apply filter_ prefixed params + foreach ($params as $key => $value) { + if (str_starts_with((string)$key, 'filter_') && $value !== '' && $value !== null) { + $fieldName = substr((string)$key, 7); + $this->applyFilter($c, $fieldName, $value); + } + } + + $this->applyDirectFilters($c, $params); + + // Calculate sum and count + $c->select('SUM(msOrder.cost) as sum, COUNT(msOrder.id) as total'); + $c->prepare(); + $c->stmt->execute(); + $data = $c->stmt->fetch(\PDO::FETCH_ASSOC); + + return [ + 'month_sum' => number_format(round($data['sum'] ?? 0), 0, '.', ' '), + 'month_total' => number_format($data['total'] ?? 0, 0, '.', ' '), + ]; + } + + /** + * @param array $params + */ + protected function hasAddressFilter(array $params): bool + { + foreach (self::ADDRESS_FILTER_KEYS as $fieldName) { + $value = $params['filter_' . $fieldName] ?? null; + if ($value !== null && $value !== '') { + return true; + } + } + + return false; + } + + /** + * Whether draft orders should be included in the manager orders list query. + * + * When `show_drafts` is present in request params it overrides `ms3_order_show_drafts`. + * The Vue orders grid always sends this flag (initialized from ms3.config.order_show_drafts). + * + * @param array $params + */ + protected function shouldShowDrafts(array $params): bool + { + $default = (bool)$this->modx->getOption('ms3_order_show_drafts', null, false); + + if (!array_key_exists('show_drafts', $params)) { + return $default; + } + + $value = $params['show_drafts']; + if ($value === '' || $value === null) { + return $default; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Exclude draft status from getList query unless drafts are explicitly shown. + * + * @param array $params + */ + protected function applyDraftVisibilityFilter(xPDOQuery $c, array $params): void + { + if ($this->shouldShowDrafts($params)) { + return; + } + + $statusDrafts = (int)$this->modx->getOption('ms3_status_draft', null, 1) ?: 1; + $c->where(['status_id:!=' => $statusDrafts]); + } + + /** + * @param mixed $value + */ + protected function applyFilter(xPDOQuery $c, string $fieldName, mixed $value): void + { + ManagerListFilterPolicy::applyOrderFilter($c, $fieldName, $value); + } + + /** + * Apply direct (unprefixed) filter params to an orders query. + * + * @param array $params + */ + protected function applyDirectFilters(xPDOQuery $c, array $params): void + { + foreach (self::DIRECT_FILTER_FIELD_MAP as $paramKey => $fieldName) { + $value = $params[$paramKey] ?? null; + if ($value === null || $value === '') { + continue; + } + + if (in_array($paramKey, self::DIRECT_FILTER_INT_KEYS, true)) { + $value = (int)$value; + } + + $c->where([$fieldName => $value]); + } + + $dateFrom = $params['createdon_from'] ?? $params['date_start'] ?? null; + if ($dateFrom) { + $c->where([ + 'msOrder.createdon:>=' => date('Y-m-d 00:00:00', strtotime((string)$dateFrom)), + ]); + } + + $dateTo = $params['createdon_to'] ?? $params['date_end'] ?? null; + if ($dateTo) { + $c->where([ + 'msOrder.createdon:<=' => date('Y-m-d 23:59:59', strtotime((string)$dateTo)), + ]); + } + } + + /** + * Map sort field to database column. + */ + protected function mapSortField(string $sort): string + { + // Model fields mapping + $mapping = [ + 'id' => 'msOrder.id', + 'num' => 'msOrder.num', + 'cost' => 'msOrder.cost', + 'cart_cost' => 'msOrder.cart_cost', + 'delivery_cost' => 'msOrder.delivery_cost', + 'weight' => 'msOrder.weight', + 'createdon' => 'msOrder.createdon', + 'updatedon' => 'msOrder.updatedon', + 'status_id' => 'msOrder.status_id', + 'delivery_id' => 'msOrder.delivery_id', + 'payment_id' => 'msOrder.payment_id', + 'context' => 'msOrder.context', + ]; + + // For relation fields (status_name, delivery_name, etc.) - sort by the alias + // This works because we SELECT them AS `field_name` + return $mapping[$sort] ?? 'msOrder.id'; + } +} diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php b/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php new file mode 100644 index 00000000..90000ea0 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/ManagerOrderMutationService.php @@ -0,0 +1,476 @@ +modx = $modx; + $this->presenter = $presenter ?? new ManagerOrderPresenter($modx); + $this->orderLog = $orderLog; + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function create(array $params = []): array + { + $createCustomer = !empty($params['create_customer']); + $forceCreateCustomer = !empty($params['force_create_customer']); + $customerId = (int)($params['customer_id'] ?? 0); + + // Handle customer creation + if ($createCustomer && $customerId === 0) { + // Validate customer data - need at least email or phone + $email = trim((string)($params['email'] ?? '')); + $phone = trim((string)($params['phone'] ?? '')); + + if (empty($email) && empty($phone)) { + return $this->error( + (string)$this->modx->lexicon('ms3_order_err_customer_contact'), + HttpStatus::BAD_REQUEST, + ['fields' => ['email', 'phone']] + ); + } + + /** @var CustomerDuplicateChecker $duplicateChecker */ + $duplicateChecker = $this->modx->services->get('ms3_customer_duplicate_checker'); + + // Check for duplicates (unless forcing creation) + if (!$forceCreateCustomer && $duplicateChecker->hasCheckableData($params)) { + $existingCustomer = $duplicateChecker->findDuplicate($params); + + if ($existingCustomer) { + // Return duplicate info for user decision + return $this->success([ + 'duplicate_found' => true, + 'customer' => [ + 'id' => $existingCustomer->get('id'), + 'first_name' => $existingCustomer->get('first_name'), + 'last_name' => $existingCustomer->get('last_name'), + 'email' => $existingCustomer->get('email'), + 'phone' => $existingCustomer->get('phone'), + 'orders_count' => $existingCustomer->get('orders_count'), + 'total_spent' => $existingCustomer->get('total_spent'), + ], + ], 'Customer with matching data already exists'); + } + } + + // Create new customer + try { + /** @var CustomerFactory $customerFactory */ + $customerFactory = $this->modx->services->get('ms3_customer_factory'); + $customer = $customerFactory->createFromOrderData($params); + $customerId = (int)$customer->get('id'); + + $this->modx->log( + modX::LOG_LEVEL_INFO, + "[ManagerOrderMutationService] Created new customer #{$customerId} from order data" + ); + } catch (\Exception $e) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + '[ManagerOrderMutationService] Failed to create customer: ' . $e->getMessage() + ); + + return $this->error( + 'Failed to create customer: ' . $e->getMessage(), + HttpStatus::INTERNAL_SERVER_ERROR + ); + } + } + + $order = $this->modx->newObject(msOrder::class); + $order->set('uuid', (string)Uuid::uuid4()); + $order->set('token', md5(uniqid('ms3_mgr_', true))); + + // Set status to "Draft" - order will be finalized later + $statusDraft = (int)$this->modx->getOption('ms3_status_draft', null, 1) ?: 1; + $order->set('status_id', $statusDraft); + + $order->set('context', $params['context'] ?? 'web'); + $order->set('createdon', time()); + $order->set('updatedon', time()); + $order->set('user_id', $this->modx->user->get('id')); + $order->set('customer_id', $customerId); + $order->set('delivery_id', (int)($params['delivery_id'] ?? 0)); + $order->set('payment_id', (int)($params['payment_id'] ?? 0)); + $order->set('order_comment', $params['order_comment'] ?? ''); + + // Order number will be generated on finalization (NULL so UNIQUE allows many drafts) + $order->set('num', null); + + // Initial costs (will be recalculated after adding products) + $order->set('cart_cost', 0); + /** @var OrderService $orderService */ + $orderService = $this->modx->services->get('ms3_order_service'); + $deliveryCost = (float)($params['delivery_cost'] ?? 0); + $order->set('delivery_cost', $deliveryCost); + $order->set('cost', $orderService->clampComputedTotal(null, 0.0, $deliveryCost, 0.0)); + $order->set('weight', 0); + + if (!$order->save()) { + return $this->error('Failed to create order', HttpStatus::INTERNAL_SERVER_ERROR); + } + + $address = $this->modx->newObject(msOrderAddress::class); + $address->set('order_id', $order->get('id')); + $address->set('createdon', time()); + + // Address fields from params + // Safe to use array_key_exists: new entity, no previous value to silently overwrite; + // all address columns are nullable VARCHAR/TEXT. + $addressFields = [ + 'first_name', 'last_name', 'phone', 'email', + 'country', 'index', 'region', 'city', 'metro', + 'street', 'building', 'entrance', 'floor', 'room', + 'comment', 'text_address', + ]; + foreach ($addressFields as $field) { + if (array_key_exists($field, $params)) { + $address->set($field, $params[$field]); + } + } + $address->save(); + + // Log draft creation + $this->getOrderLog()->addEntry( + (int)$order->get('id'), + msOrderLog::ACTION_STATUS, + [ + 'old_status_id' => 0, + 'new_status_id' => $statusDraft, + 'old_status_name' => '', + 'new_status_name' => $this->presenter->getStatusName($statusDraft), + ] + ); + + // Return created order with address data + $orderData = $order->toArray(); + $orderData = $this->presenter->mergeAddressIntoOrderData($orderData, $address); + $orderData['customer_created'] = $createCustomer && $customerId > 0; + + return $this->success( + $this->presenter->formatOrder($orderData), + 'Order draft created' + ); + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function update(array $params = []): array + { + $id = (int)($params['id'] ?? 0); + if (!$id) { + return $this->error('Order ID is required', HttpStatus::BAD_REQUEST); + } + + /** @var msOrder|null $order */ + $order = $this->modx->getObject(msOrder::class, $id); + if (!$order) { + return $this->error('Order not found', HttpStatus::NOT_FOUND); + } + + // Store old values for logging + $oldStatusId = (int)$order->get('status_id'); + + // Get editable order fields from msModelField configuration + $orderFields = $this->presenter->getModelFieldNames('msOrder'); + $changedOrderFields = []; + + foreach ($orderFields as $field) { + if (array_key_exists($field, $params)) { + $oldValue = $order->get($field); + $newValue = $params[$field]; + if ($oldValue != $newValue) { + $changedOrderFields[$field] = ['old' => $oldValue, 'new' => $newValue]; + } + $order->set($field, $newValue); + } + } + + // Handle extra fields for msOrder (stored as real DB columns via Object Extension) + $orderExtraFields = $this->presenter->getExtraFieldKeys('MiniShop3\\Model\\msOrder'); + foreach ($orderExtraFields as $extraFieldKey) { + if (!array_key_exists($extraFieldKey, $params)) { + continue; + } + + $normalized = $this->presenter->normalizeExtraFieldValue( + 'MiniShop3\\Model\\msOrder', + $extraFieldKey, + $params[$extraFieldKey] + ); + if (!$normalized['ok']) { + return $this->error( + (string)$normalized['message'], + HttpStatus::UNPROCESSABLE_ENTITY + ); + } + + $newValue = $normalized['value']; + $oldValue = $order->get($extraFieldKey); + if ($oldValue != $newValue) { + $changedOrderFields[$extraFieldKey] = ['old' => $oldValue, 'new' => $newValue]; + } + $order->set($extraFieldKey, $newValue); + } + + $order->set('updatedon', date('Y-m-d H:i:s')); + if (!$order->save()) { + return $this->error('Failed to update order', HttpStatus::INTERNAL_SERVER_ERROR); + } + + // Log order field changes (excluding status_id which is logged separately) + unset($changedOrderFields['status_id']); + if (!empty($changedOrderFields)) { + $this->getOrderLog()->addEntry( + $id, + msOrderLog::ACTION_FIELD, + ['fields' => $changedOrderFields] + ); + } + + // Handle address fields + $address = $this->modx->getObject(msOrderAddress::class, ['order_id' => $id]); + if ($address) { + $changedAddressFields = []; + + // Get editable address fields from msModelField configuration + $addressFields = $this->presenter->getModelFieldNames('msOrderAddress'); + foreach ($addressFields as $field) { + if (array_key_exists($field, $params)) { + $oldValue = $address->get($field); + $newValue = $params[$field]; + if ($oldValue != $newValue) { + $changedAddressFields[$field] = ['old' => $oldValue, 'new' => $newValue]; + } + $address->set($field, $newValue); + } + } + + // Handle extra fields for msOrderAddress (stored as real DB columns via Object Extension) + $addressExtraFields = $this->presenter->getExtraFieldKeys('MiniShop3\\Model\\msOrderAddress'); + foreach ($addressExtraFields as $extraFieldKey) { + if (!array_key_exists($extraFieldKey, $params)) { + continue; + } + + $normalized = $this->presenter->normalizeExtraFieldValue( + 'MiniShop3\\Model\\msOrderAddress', + $extraFieldKey, + $params[$extraFieldKey] + ); + if (!$normalized['ok']) { + return $this->error( + (string)$normalized['message'], + HttpStatus::UNPROCESSABLE_ENTITY + ); + } + + $newValue = $normalized['value']; + $oldValue = $address->get($extraFieldKey); + if ($oldValue != $newValue) { + $changedAddressFields[$extraFieldKey] = ['old' => $oldValue, 'new' => $newValue]; + } + $address->set($extraFieldKey, $newValue); + } + + $address->save(); + + // Log address changes + if (!empty($changedAddressFields)) { + $this->getOrderLog()->addEntry( + $id, + msOrderLog::ACTION_ADDRESS, + ['fields' => $changedAddressFields] + ); + } + } + + // Handle status change via OrderStatusService (sends notifications) + $newStatusId = (int)$order->get('status_id'); + if ($oldStatusId !== $newStatusId) { + // Revert status to old value - OrderStatusService will change it properly + $order->set('status_id', $oldStatusId); + $order->save(); + + /** @var OrderStatusService $orderStatusService */ + $orderStatusService = $this->modx->services->get('ms3_order_status'); + $result = $orderStatusService->change((int)$order->get('id'), $newStatusId); + + if ($result !== true) { + return $this->error( + is_string($result) ? $result : (string)$this->modx->lexicon('ms3_err_status_change'), + HttpStatus::BAD_REQUEST + ); + } + + // Reload order to get updated data + $order = $this->modx->getObject(msOrder::class, $id); + if (!$order instanceof msOrder) { + return $this->error('Order not found after update', HttpStatus::INTERNAL_SERVER_ERROR); + } + } + + // Return the same shape as GET (address-merge, formatted cost, no secret fields) + return $this->success( + $this->presenter->buildOrderPayloadFromModel($order), + 'Order updated successfully' + ); + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function delete(array $params = []): array + { + $id = (int)($params['id'] ?? 0); + if (!$id) { + return $this->error('Order ID is required', HttpStatus::BAD_REQUEST); + } + + $order = $this->modx->getObject(msOrder::class, $id); + if (!$order) { + return $this->error('Order not found', HttpStatus::NOT_FOUND); + } + + $addresses = $this->modx->getIterator(msOrderAddress::class, ['order_id' => $id]); + foreach ($addresses as $address) { + $address->remove(); + } + + $products = $this->modx->getIterator(\MiniShop3\Model\msOrderProduct::class, ['order_id' => $id]); + foreach ($products as $product) { + $product->remove(); + } + + if (!$order->remove()) { + return $this->error('Failed to delete order', HttpStatus::INTERNAL_SERVER_ERROR); + } + + return $this->success([], 'Order deleted successfully'); + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function bulkDelete(array $params = []): array + { + $ids = $params['ids'] ?? []; + if (empty($ids) || !is_array($ids)) { + return $this->error('Order IDs array is required', HttpStatus::BAD_REQUEST); + } + + // Sanitize IDs + $ids = array_filter(array_map('intval', $ids), static fn($id) => $id > 0); + if (empty($ids)) { + return $this->error('No valid order IDs provided', HttpStatus::BAD_REQUEST); + } + + $deleted = 0; + $failed = 0; + + foreach ($ids as $id) { + $order = $this->modx->getObject(msOrder::class, $id); + if (!$order) { + $failed++; + continue; + } + + $addresses = $this->modx->getIterator(msOrderAddress::class, ['order_id' => $id]); + foreach ($addresses as $address) { + $address->remove(); + } + + $products = $this->modx->getIterator(\MiniShop3\Model\msOrderProduct::class, ['order_id' => $id]); + foreach ($products as $product) { + $product->remove(); + } + + $logs = $this->modx->getIterator(msOrderLog::class, ['order_id' => $id]); + foreach ($logs as $log) { + $log->remove(); + } + + if ($order->remove()) { + $deleted++; + } else { + $failed++; + } + } + + if ($deleted === 0) { + return $this->error('Failed to delete orders', HttpStatus::INTERNAL_SERVER_ERROR); + } + + return $this->success( + ['deleted' => $deleted, 'failed' => $failed], + "Deleted {$deleted} orders" + ); + } + + protected function getOrderLog(): OrderLogService + { + if ($this->orderLog === null) { + if ($this->modx->services->has('ms3_order_log')) { + $this->orderLog = $this->modx->services->get('ms3_order_log'); + } else { + /** @var MiniShop3 $ms3 */ + $ms3 = $this->modx->services->get('ms3'); + $this->orderLog = new OrderLogService($this->modx, $ms3); + } + } + + return $this->orderLog; + } + + /** + * @param array $data + * @return array{success: true, message: string, data: array, status: int} + */ + protected function success(array $data = [], string $message = ''): array + { + return [ + 'success' => true, + 'message' => $message, + 'data' => $data, + 'status' => HttpStatus::OK, + ]; + } + + /** + * @param array $data + * @return array{success: false, message: string, data: array, status: int} + */ + protected function error(string $message, int $status, array $data = []): array + { + return [ + 'success' => false, + 'message' => $message, + 'data' => $data, + 'status' => $status, + ]; + } +} diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderPresenter.php b/core/components/minishop3/src/Services/Order/ManagerOrderPresenter.php new file mode 100644 index 00000000..f8105247 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/ManagerOrderPresenter.php @@ -0,0 +1,246 @@ +modx = $modx; + } + + /** + * Merge address fields into order payload without overwriting order-level fields. + * + * msOrderAddress has its own `id`, `properties`, `createdon`, `updatedon` which + * must not overwrite the corresponding msOrder fields in the API response. + * + * Note: extra fields for msOrderAddress are loaded separately in get() since + * they require explicit column reads; create()/finalize() return transient + * responses where the address is either empty or just created. + */ + public function mergeAddressIntoOrderData(array $orderData, ?xPDOObject $address): array + { + if (!$address) { + return $orderData; + } + + $excludeFields = ['id', 'order_id', 'createdon', 'updatedon', 'properties']; + foreach ($address->toArray() as $key => $value) { + if (!in_array($key, $excludeFields, true)) { + $orderData[$key] = $value; + } + } + + return $orderData; + } + + /** + * Снимок данных заказа для API карточки (как в get() после загрузки). + */ + public function buildOrderPayloadFromModel(msOrder $order): array + { + $status = $order->getOne('Status'); + $delivery = $order->getOne('Delivery'); + $payment = $order->getOne('Payment'); + $address = $order->getOne('Address'); + + $data = $order->toArray(); + $data['status_name'] = $status ? $status->get('name') : ''; + $data['color'] = $status ? $status->get('color') : ''; + $data['delivery_name'] = $delivery ? $delivery->get('name') : ''; + $data['payment_name'] = $payment ? $payment->get('name') : ''; + + $orderExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrder'); + foreach ($orderExtraFields as $fieldKey) { + $data[$fieldKey] = $order->get($fieldKey); + } + + $data = $this->mergeAddressIntoOrderData($data, $address); + + if ($address) { + $addressExtraFields = $this->getExtraFieldKeys('MiniShop3\\Model\\msOrderAddress'); + foreach ($addressExtraFields as $fieldKey) { + $data[$fieldKey] = $address->get($fieldKey); + } + } + + return $this->formatOrder($data); + } + + /** + * Format order data for API response. + */ + public function formatOrder(array $data): array + { + $ms3 = $this->modx->services->get('ms3'); + + if (!empty($data['status_name']) && str_starts_with($data['status_name'], 'ms3_')) { + $translated = $this->modx->lexicon($data['status_name']); + if ($translated !== $data['status_name']) { + $data['status_name'] = $translated; + } + } + + if (!empty($data['delivery_name']) && str_starts_with($data['delivery_name'], 'ms3_')) { + $translated = $this->modx->lexicon($data['delivery_name']); + if ($translated !== $data['delivery_name']) { + $data['delivery_name'] = $translated; + } + } + + if (!empty($data['payment_name']) && str_starts_with($data['payment_name'], 'ms3_')) { + $translated = $this->modx->lexicon($data['payment_name']); + if ($translated !== $data['payment_name']) { + $data['payment_name'] = $translated; + } + } + + $data['customer'] = trim(implode(' ', [ + $data['first_name'] ?? '', + $data['last_name'] ?? '', + ])); + + if ($ms3) { + if (isset($data['cost'])) { + $data['cost_formatted'] = $ms3->format->price($data['cost']); + } + if (isset($data['cart_cost'])) { + $data['cart_cost_formatted'] = $ms3->format->price($data['cart_cost']); + } + if (isset($data['delivery_cost'])) { + $data['delivery_cost_formatted'] = $ms3->format->price($data['delivery_cost']); + } + if (isset($data['weight'])) { + $data['weight_formatted'] = $ms3->format->weight($data['weight']); + } + } + + foreach (self::HIDDEN_ORDER_FIELDS as $field) { + unset($data[$field]); + } + + return $data; + } + + /** + * @return array{ok: bool, value?: mixed, message?: string} + */ + public function normalizeExtraFieldValue(string $modelClass, string $fieldKey, mixed $value): array + { + /** @var msExtraField|null $definition */ + $definition = $this->modx->getObject(msExtraField::class, [ + 'class' => $modelClass, + 'key' => $fieldKey, + 'active' => true, + ]); + + if (!$definition || $definition->get('xtype') !== RepeaterFieldService::XTYPE) { + return ['ok' => true, 'value' => $value]; + } + + /** @var RepeaterFieldService $repeaterService */ + $repeaterService = $this->modx->services->get('ms3_repeater_field'); + $config = $repeaterService->parseConfig($definition->get('repeater_config')); + + try { + return ['ok' => true, 'value' => $repeaterService->processValue($value, $config)]; + } catch (\InvalidArgumentException $e) { + $this->modx->lexicon->load('minishop3:default'); + + return [ + 'ok' => false, + 'message' => $this->modx->lexicon('ms3_repeater_validation_error', [ + 'field' => $fieldKey, + 'error' => $e->getMessage(), + ]), + ]; + } + } + + /** + * Get extra field keys for a specific model class. + */ + public function getExtraFieldKeys(string $modelClass): array + { + $keys = []; + + $query = $this->modx->newQuery(msExtraField::class); + $query->where([ + 'class' => $modelClass, + 'active' => true, + ]); + + foreach ($this->modx->getIterator(msExtraField::class, $query) as $field) { + $keys[] = $field->get('key'); + } + + return $keys; + } + + /** + * Get field names from msModelField configuration. + */ + public function getModelFieldNames(string $model): array + { + $names = []; + + $query = $this->modx->newQuery(msModelField::class); + $query->where(['model' => $model]); + + foreach ($this->modx->getIterator(msModelField::class, $query) as $field) { + $names[] = $field->get('name'); + } + + return $names; + } + + /** Human-readable lexicon entry, or original key when missing/empty translation. */ + public function lexiconMessageOrKey(string $key): string + { + $text = $this->modx->lexicon($key); + + return ($text !== $key && $text !== '') ? $text : $key; + } + + /** + * Get status name by ID. + */ + public function getStatusName(int $statusId): string + { + $status = $this->modx->getObject(msOrderStatus::class, $statusId); + if (!$status) { + return (string)$statusId; + } + + $name = $status->get('name'); + if (str_starts_with($name, 'ms3_order_status_')) { + $translated = $this->modx->lexicon($name); + if ($translated !== $name) { + return $translated; + } + } + + return $name; + } +} diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php b/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php new file mode 100644 index 00000000..ff19a569 --- /dev/null +++ b/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php @@ -0,0 +1,506 @@ +modx = $modx; + $this->orderLog = $orderLog; + $this->ms3Utils = $ms3Utils; + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function getProducts(array $params = []): array + { + $id = (int)($params['id'] ?? 0); + if (!$id) { + return $this->error('Order ID is required', HttpStatus::BAD_REQUEST); + } + + $c = $this->modx->newQuery(msOrderProduct::class); + $c->where(['order_id' => $id]); + $c->leftJoin(msProduct::class, 'Product', 'msOrderProduct.product_id = Product.id'); + $c->select($this->modx->getSelectColumns(msOrderProduct::class, 'msOrderProduct')); + $c->select(['Product.pagetitle']); + + $products = []; + $collection = $this->modx->getIterator(msOrderProduct::class, $c); + foreach ($collection as $product) { + $data = $product->toArray(); + $data['pagetitle'] = $product->get('pagetitle'); + $products[] = $data; + } + + return $this->success(['results' => $products]); + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function addProduct(array $params = []): array + { + $orderId = (int)($params['id'] ?? 0); + $productId = (int)($params['product_id'] ?? 0); + + if (!$orderId) { + return $this->error('Order ID is required', HttpStatus::BAD_REQUEST); + } + if (!$productId) { + return $this->error('Product ID is required', HttpStatus::BAD_REQUEST); + } + + // Get the order + $order = $this->modx->getObject(msOrder::class, $orderId); + if (!$order) { + return $this->error('Order not found', HttpStatus::NOT_FOUND); + } + + // Check order status (should not be final) + $status = $this->modx->getObject(msOrderStatus::class, $order->get('status_id')); + if ($status && $status->get('final')) { + return $this->error('Cannot add products to finalized order', HttpStatus::BAD_REQUEST); + } + + // Get the product + $product = $this->modx->getObject(msProduct::class, $productId); + if (!$product) { + return $this->error('Product not found', HttpStatus::NOT_FOUND); + } + + // Get product data + $productData = $this->modx->getObject(msProductData::class, $productId); + + // Get values from params or product defaults + $count = (int)($params['count'] ?? 1); + if ($count < 1) { + $count = 1; + } + + $price = isset($params['price']) + ? (float)$params['price'] + : ($productData ? (float)$productData->get('price') : 0); + $weight = isset($params['weight']) + ? (float)$params['weight'] + : ($productData ? (float)$productData->get('weight') : 0); + $name = $params['name'] ?? $product->get('pagetitle'); + + // Handle options + $options = []; + if (isset($params['options'])) { + if (is_string($params['options'])) { + $decoded = json_decode($params['options'], true); + if (json_last_error() === JSON_ERROR_NONE) { + $options = $decoded; + } + } elseif (is_array($params['options'])) { + $options = $params['options']; + } + } + + // Generate product_key (unique identifier for product variation) + $productKey = md5($productId . json_encode($options)); + + // Create order product record + $orderProduct = $this->modx->newObject(msOrderProduct::class); + $orderProduct->set('order_id', $orderId); + $orderProduct->set('product_id', $productId); + $orderProduct->set('product_key', $productKey); + $orderProduct->set('name', $name); + $orderProduct->set('count', $count); + $orderProduct->set('price', $price); + $orderProduct->set('weight', $weight); + $orderProduct->set('cost', $count * $price); + $orderProduct->set('options', !empty($options) ? json_encode($options) : null); + + $eventContext = [ + 'mode' => modSystemEvent::MODE_NEW, + // 'object' — MS2-style alias, 'msOrderProduct' — MS3-style; both point at the same row + 'object' => $orderProduct, + 'msOrderProduct' => $orderProduct, + 'msOrder' => $order, + ]; + + $response = $this->getMs3Utils()->invokeEvent('msOnBeforeCreateOrderProduct', $eventContext); + if (!$response['success']) { + return $this->error((string)$response['message'], HttpStatus::BAD_REQUEST); + } + + if (!$orderProduct->save()) { + return $this->error('Failed to add product to order', HttpStatus::INTERNAL_SERVER_ERROR); + } + + // After-event: row already persisted, a plugin error cannot roll it back. Log and continue + // so the client doesn't get a 4xx for a request that actually succeeded server-side. + $response = $this->getMs3Utils()->invokeEvent('msOnCreateOrderProduct', $eventContext); + if (!$response['success']) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[ms3] msOnCreateOrderProduct after-plugin reported error (persistence already done): ' + . $response['message'] + ); + } + + // Log product addition + $this->getOrderLog()->addEntry( + $orderId, + msOrderLog::ACTION_PRODUCTS, + [ + 'operation' => 'add', + 'product_id' => $productId, + 'product_name' => $name, + 'count' => $count, + 'price' => $price, + 'cost' => $count * $price, + ] + ); + + // Recalculate order totals + $this->recalculateOrderTotals($order); + + // Return created product data + $result = $orderProduct->toArray(); + $result['pagetitle'] = $product->get('pagetitle'); + + return $this->success($result, 'Product added to order successfully'); + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function updateProduct(array $params = []): array + { + $orderId = (int)($params['id'] ?? 0); + $productId = (int)($params['product_id'] ?? 0); + + if (!$orderId || !$productId) { + return $this->error('Order ID and Product ID are required', HttpStatus::BAD_REQUEST); + } + + // Find the order product record + $orderProduct = $this->modx->getObject(msOrderProduct::class, [ + 'id' => $productId, + 'order_id' => $orderId, + ]); + + if (!$orderProduct) { + return $this->error('Order product not found', HttpStatus::NOT_FOUND); + } + + // Get the order to recalculate totals + $order = $this->modx->getObject(msOrder::class, $orderId); + if (!$order) { + return $this->error('Order not found', HttpStatus::NOT_FOUND); + } + + // Store old values for logging + $oldValues = [ + 'count' => $orderProduct->get('count'), + 'price' => $orderProduct->get('price'), + 'weight' => $orderProduct->get('weight'), + 'cost' => $orderProduct->get('cost'), + ]; + + // Allowed fields for update + $allowedFields = ['count', 'price', 'weight', 'options']; + $updated = false; + $changes = []; + + // Per-field null semantics: + // - `options` (JSON): null = explicit clear from frontend (OrderView getOptionsForSave()) + // - `count`/`price`/`weight` (numeric): null skipped — coercing null to 0/1 would + // silently destroy data when a partial payload accidentally carries `null` + // (PrimeVue InputNumber on clear, third-party API, batch scripts). + // Frontend must send explicit 0 / valid number to actually update these. + $nullClearable = ['options']; + + foreach ($allowedFields as $field) { + if (!array_key_exists($field, $params)) { + continue; + } + $value = $params[$field]; + + if ($value === null && !in_array($field, $nullClearable, true)) { + continue; + } + + $oldValue = $orderProduct->get($field); + + switch ($field) { + case 'count': + $value = max(1, (int)$value); + break; + case 'price': + case 'weight': + $value = max(0, (float)$value); + break; + case 'options': + // null passes through (clear), arrays get encoded as JSON + if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_UNICODE); + } + break; + } + + // Track changes for logging + if ($oldValue != $value) { + $changes[$field] = ['old' => $oldValue, 'new' => $value]; + } + + $orderProduct->set($field, $value); + $updated = true; + } + + if (!$updated) { + return $this->error('No fields to update', HttpStatus::BAD_REQUEST); + } + + // Recalculate cost + $count = (float)$orderProduct->get('count'); + $price = (float)$orderProduct->get('price'); + $newCost = $count * $price; + $orderProduct->set('cost', $newCost); + + // Add cost change if it changed + if ($oldValues['cost'] != $newCost) { + $changes['cost'] = ['old' => $oldValues['cost'], 'new' => $newCost]; + } + + $eventContext = [ + 'mode' => modSystemEvent::MODE_UPD, + // 'object' — MS2-style alias, 'msOrderProduct' — MS3-style; both point at the same row + 'object' => $orderProduct, + 'msOrderProduct' => $orderProduct, + 'msOrder' => $order, + ]; + + $response = $this->getMs3Utils()->invokeEvent('msOnBeforeUpdateOrderProduct', $eventContext); + if (!$response['success']) { + return $this->error((string)$response['message'], HttpStatus::BAD_REQUEST); + } + + if (!$orderProduct->save()) { + return $this->error('Failed to update order product', HttpStatus::INTERNAL_SERVER_ERROR); + } + + // After-event: row already persisted, a plugin error cannot roll it back. Log and continue + // so the client doesn't get a 4xx for a request that actually succeeded server-side. + $response = $this->getMs3Utils()->invokeEvent('msOnUpdateOrderProduct', $eventContext); + if (!$response['success']) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[ms3] msOnUpdateOrderProduct after-plugin reported error (persistence already done): ' + . $response['message'] + ); + } + + // Log product update if there were changes + if (!empty($changes)) { + $this->getOrderLog()->addEntry( + $orderId, + msOrderLog::ACTION_PRODUCTS, + [ + 'operation' => 'update', + 'product_id' => $orderProduct->get('product_id'), + 'product_name' => $orderProduct->get('name'), + 'changes' => $changes, + ] + ); + } + + // Recalculate order totals + $this->recalculateOrderTotals($order); + + return $this->success( + $orderProduct->toArray(), + 'Order product updated successfully' + ); + } + + /** + * @param array $params + * @return array{success: bool, message: string, data: array, status: int} + */ + public function deleteProduct(array $params = []): array + { + $orderId = (int)($params['id'] ?? 0); + $productId = (int)($params['product_id'] ?? 0); + + if (!$orderId || !$productId) { + return $this->error('Order ID and Product ID are required', HttpStatus::BAD_REQUEST); + } + + // Find the order product record + $orderProduct = $this->modx->getObject(msOrderProduct::class, [ + 'id' => $productId, + 'order_id' => $orderId, + ]); + + if (!$orderProduct) { + return $this->error('Order product not found', HttpStatus::NOT_FOUND); + } + + // Get the order to recalculate totals + $order = $this->modx->getObject(msOrder::class, $orderId); + if (!$order) { + return $this->error('Order not found', HttpStatus::NOT_FOUND); + } + + // Check if this is the last product + $productCount = $this->modx->getCount(msOrderProduct::class, ['order_id' => $orderId]); + if ($productCount <= 1) { + return $this->error('Cannot delete the last product from order', HttpStatus::BAD_REQUEST); + } + + // Store data for logging before removal + $productData = [ + 'product_id' => $orderProduct->get('product_id'), + 'product_name' => $orderProduct->get('name'), + 'count' => $orderProduct->get('count'), + 'price' => $orderProduct->get('price'), + 'cost' => $orderProduct->get('cost'), + ]; + + $eventContext = [ + 'id' => $orderProduct->get('id'), + // 'object' — MS2-style alias, 'msOrderProduct' — MS3-style; both point at the same row + 'object' => $orderProduct, + 'msOrderProduct' => $orderProduct, + 'msOrder' => $order, + ]; + + $response = $this->getMs3Utils()->invokeEvent('msOnBeforeRemoveOrderProduct', $eventContext); + if (!$response['success']) { + return $this->error((string)$response['message'], HttpStatus::BAD_REQUEST); + } + + if (!$orderProduct->remove()) { + return $this->error('Failed to delete order product', HttpStatus::INTERNAL_SERVER_ERROR); + } + + // After-event: row already removed, a plugin error cannot roll it back. Log and continue + // so the client doesn't get a 4xx for a request that actually succeeded server-side. + $response = $this->getMs3Utils()->invokeEvent('msOnRemoveOrderProduct', $eventContext); + if (!$response['success']) { + $this->modx->log( + modX::LOG_LEVEL_WARN, + '[ms3] msOnRemoveOrderProduct after-plugin reported error (persistence already done): ' + . $response['message'] + ); + } + + // Log product removal + $this->getOrderLog()->addEntry( + $orderId, + msOrderLog::ACTION_PRODUCTS, + [ + 'operation' => 'remove', + 'product_id' => $productData['product_id'], + 'product_name' => $productData['product_name'], + 'count' => $productData['count'], + 'price' => $productData['price'], + 'cost' => $productData['cost'], + ] + ); + + // Recalculate order totals + $this->recalculateOrderTotals($order); + + return $this->success(null, 'Order product deleted successfully'); + } + + public function recalculateOrderTotals(msOrder $order): void + { + $products = $this->modx->getIterator(msOrderProduct::class, [ + 'order_id' => $order->get('id'), + ]); + $totals = OrderService::aggregateProductsTotals($products); + $cartCost = $totals['cart_cost']; + $weight = $totals['weight']; + + $order->set('cart_cost', $cartCost); + $order->set('weight', $weight); + // Recalculate total cost (cart + delivery; payment deltas are reflected in cost when persisted elsewhere) + /** @var OrderService $orderService */ + $orderService = $this->modx->services->get('ms3_order_service'); + $deliveryCost = (float)$order->get('delivery_cost'); + $order->set('cost', $orderService->clampComputedTotal($order, $cartCost, $deliveryCost, 0.0)); + + $order->save(); + } + + protected function getOrderLog(): OrderLogService + { + if ($this->orderLog === null) { + if ($this->modx->services->has('ms3_order_log')) { + $this->orderLog = $this->modx->services->get('ms3_order_log'); + } else { + /** @var MiniShop3 $ms3 */ + $ms3 = $this->modx->services->get('ms3'); + $this->orderLog = new OrderLogService($this->modx, $ms3); + } + } + + return $this->orderLog; + } + + protected function getMs3Utils(): Utils + { + if ($this->ms3Utils === null) { + /** @var MiniShop3 $ms3 */ + $ms3 = $this->modx->services->get('ms3'); + $this->ms3Utils = $ms3->utils; + } + + return $this->ms3Utils; + } + + /** + * @return array{success: true, message: string, data: mixed, status: int} + */ + protected function success(mixed $data = [], string $message = ''): array + { + return [ + 'success' => true, + 'message' => $message, + 'data' => $data, + 'status' => HttpStatus::OK, + ]; + } + + /** + * @param array $data + * @return array{success: false, message: string, data: array, status: int} + */ + protected function error(string $message, int $status, array $data = []): array + { + return [ + 'success' => false, + 'message' => $message, + 'data' => $data, + 'status' => $status, + ]; + } +} diff --git a/core/components/minishop3/tests/ManagerOrdersDecompositionTest.php b/core/components/minishop3/tests/ManagerOrdersDecompositionTest.php new file mode 100644 index 00000000..7d25e207 --- /dev/null +++ b/core/components/minishop3/tests/ManagerOrdersDecompositionTest.php @@ -0,0 +1,73 @@ += 1000) { + $fail("OrdersController is too large: {$lineCount} lines (must be < 1000)"); +} + +$requiredServiceFiles = [ + __DIR__ . '/../src/Services/Order/ManagerOrderPresenter.php', + __DIR__ . '/../src/Services/Order/ManagerOrderListService.php', + __DIR__ . '/../src/Services/Order/ManagerOrderMutationService.php', + __DIR__ . '/../src/Services/Order/ManagerOrderProductsService.php', +]; + +foreach ($requiredServiceFiles as $path) { + if (!is_file($path)) { + $fail('Missing service file: ' . basename($path)); + } +} + +$controllerKeys = OrdersController::getDirectFilterKeys(); +$serviceKeys = ManagerOrderListService::getDirectFilterKeys(); +if ($controllerKeys !== $serviceKeys) { + $fail('OrdersController::getDirectFilterKeys must delegate list service keys'); +} + +$registryReflection = new ReflectionClass(ServiceRegistry::class); +$registry = $registryReflection->newInstanceWithoutConstructor(); +$defaultServices = $registryReflection->getProperty('defaultServices'); +$defaultServices->setAccessible(true); +$services = $defaultServices->getValue($registry); + +$requiredKeys = [ + 'ms3_manager_order_presenter', + 'ms3_manager_order_list', + 'ms3_manager_order_mutation', + 'ms3_manager_order_products', + 'ms3_manager_order_cost_recalculator', + 'ms3_extra_fields', +]; + +foreach ($requiredKeys as $key) { + if (!isset($services[$key])) { + $fail("ServiceRegistry defaultServices missing key: {$key}"); + } +} + +fwrite(STDOUT, "OK ManagerOrdersDecompositionTest ({$lineCount} lines)\n"); +exit(0); diff --git a/core/components/minishop3/tests/ServiceRegistryFactoryMapTest.php b/core/components/minishop3/tests/ServiceRegistryFactoryMapTest.php new file mode 100644 index 00000000..949c64a0 --- /dev/null +++ b/core/components/minishop3/tests/ServiceRegistryFactoryMapTest.php @@ -0,0 +1,50 @@ +newInstanceWithoutConstructor(); +$defaultServices = $reflection->getProperty('defaultServices'); +$defaultServices->setAccessible(true); +$services = $defaultServices->getValue($registry); + +$factories = ServiceRegistryFactories::map(); +foreach (array_keys($services) as $key) { + if (!isset($factories[$key])) { + $fail("Missing factory for default service key: {$key}"); + } +} + +$orphanFactories = array_diff(array_keys($factories), array_keys($services)); +if ($orphanFactories !== []) { + $fail('Factory map has keys not in defaultServices: ' . implode(', ', $orphanFactories)); +} + +echo "OK ServiceRegistryFactoryMapTest (" . count($factories) . " factories)\n"; From 4a41b5d2f817386ededcbe04cbdac84af1f6ef45 Mon Sep 17 00:00:00 2001 From: Ivan Bochkarev Date: Thu, 30 Jul 2026 10:02:46 +0600 Subject: [PATCH 2/2] =?UTF-8?q?fix(orders):=20PHPStan=20=E2=80=94=20xPDOQu?= =?UTF-8?q?ery::select=20expects=20string=20for=20pagetitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Services/Order/ManagerOrderProductsService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php b/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php index ff19a569..612b8c04 100644 --- a/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php +++ b/core/components/minishop3/src/Services/Order/ManagerOrderProductsService.php @@ -42,7 +42,7 @@ public function getProducts(array $params = []): array $c->where(['order_id' => $id]); $c->leftJoin(msProduct::class, 'Product', 'msOrderProduct.product_id = Product.id'); $c->select($this->modx->getSelectColumns(msOrderProduct::class, 'msOrderProduct')); - $c->select(['Product.pagetitle']); + $c->select('Product.pagetitle'); $products = []; $collection = $this->modx->getIterator(msOrderProduct::class, $c);