$filters * @return array{items:array>, total:int, page:int, per_page:int, error:string} */ public function paginate(array $filters): array { $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20))); $offset = ($page - 1) * $perPage; $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm'); $effectiveOrderedAtSql = $this->effectiveOrderedAtSql('o'); ['where' => $where, 'params' => $params] = $this->buildPaginateFilters($filters, $effectiveStatusSql, $effectiveOrderedAtSql); $whereSql = $where === [] ? '' : (' WHERE ' . implode(' AND ', $where)); $sort = (string) ($filters['sort'] ?? 'ordered_at'); $sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; $sortColumn = match ($sort) { 'source_order_id' => 'o.source_order_id', 'external_order_id' => 'o.external_order_id', 'status_code' => 'o.status_code', 'payment_status' => 'o.payment_status', 'total_with_tax' => 'o.total_with_tax', 'total_paid' => 'o.total_paid', 'source_updated_at' => 'o.source_updated_at', 'fetched_at' => 'o.fetched_at', 'id' => 'o.id', default => $effectiveOrderedAtSql, }; try { $countSql = 'SELECT COUNT(*) FROM orders o ' . 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" ' . 'LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code' . $whereSql; $countStmt = $this->pdo->prepare($countSql); $countStmt->execute($params); $total = (int) $countStmt->fetchColumn(); $listSql = $this->buildListSql($effectiveStatusSql, $effectiveOrderedAtSql, $whereSql, $sortColumn, $sortDir); $stmt = $this->pdo->prepare($listSql); foreach ($params as $key => $value) { $stmt->bindValue(':' . $key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); } $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT); $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(); if (!is_array($rows)) { $rows = []; } $itemPreviewsByOrderId = $this->loadOrderItemsPreviews(array_map( static fn (array $row): int => (int) ($row['id'] ?? 0), $rows )); return [ 'items' => array_map(fn (array $row) => $this->transformOrderRow($row, $itemPreviewsByOrderId), $rows), 'total' => $total, 'page' => $page, 'per_page' => $perPage, 'error' => '', ]; } catch (Throwable $exception) { return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $perPage, 'error' => $exception->getMessage()]; } } /** * @param array $filters * @return array{where:array,params:array} */ private function buildPaginateFilters(array $filters, string $effectiveStatusSql, string $effectiveOrderedAtSql): array { $where = []; $params = []; $search = trim((string) ($filters['search'] ?? '')); if ($search !== '') { $where[] = '(o.source_order_id LIKE :s1 OR o.external_order_id LIKE :s2 OR o.customer_login LIKE :s3 OR a.name LIKE :s4 OR a.email LIKE :s5 OR EXISTS (SELECT 1 FROM order_items oi_s WHERE oi_s.order_id = o.id AND oi_s.original_name LIKE :s6))'; $searchVal = '%' . $search . '%'; $params['s1'] = $searchVal; $params['s2'] = $searchVal; $params['s3'] = $searchVal; $params['s4'] = $searchVal; $params['s5'] = $searchVal; $params['s6'] = $searchVal; } $source = trim((string) ($filters['source'] ?? '')); if ($source !== '') { $where[] = 'o.source = :source'; $params['source'] = $source; } $statusGroup = trim((string) ($filters['status_group'] ?? '')); $status = trim((string) ($filters['status'] ?? '')); if ($statusGroup !== '' && ctype_digit($statusGroup)) { $groupCodes = $this->statusCodesByGroupId((int) $statusGroup); if ($groupCodes !== []) { $placeholders = []; foreach ($groupCodes as $i => $code) { $key = 'sg' . $i; $placeholders[] = ':' . $key; $params[$key] = $code; } $where[] = $effectiveStatusSql . ' IN (' . implode(', ', $placeholders) . ')'; } } elseif ($status !== '') { $where[] = $effectiveStatusSql . ' = :status'; $params['status'] = $status; } $paymentStatus = trim((string) ($filters['payment_status'] ?? '')); if ($paymentStatus !== '' && ctype_digit($paymentStatus)) { $where[] = 'o.payment_status = :payment_status'; $params['payment_status'] = (int) $paymentStatus; } $dateFrom = trim((string) ($filters['date_from'] ?? '')); if ($dateFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) === 1) { $where[] = $effectiveOrderedAtSql . ' >= :date_from'; $params['date_from'] = $dateFrom . ' 00:00:00'; } $dateTo = trim((string) ($filters['date_to'] ?? '')); if ($dateTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo) === 1) { $where[] = $effectiveOrderedAtSql . ' <= :date_to'; $params['date_to'] = $dateTo . ' 23:59:59'; } return ['where' => $where, 'params' => $params]; } private function buildListSql(string $effectiveStatusSql, string $effectiveOrderedAtSql, string $whereSql, string $sortColumn, string $sortDir): string { return 'SELECT o.id, o.internal_order_number, o.source, o.source_order_id, o.external_order_id, o.status_code, ' . $effectiveStatusSql . ' AS effective_status_id, o.payment_status, o.currency, o.total_with_tax, o.total_paid, o.ordered_at, o.source_created_at, o.source_updated_at, o.fetched_at, ' . $effectiveOrderedAtSql . ' AS effective_ordered_at, o.is_invoice, o.is_canceled_by_buyer, a.name AS buyer_name, a.email AS buyer_email, a.city AS buyer_city, o.external_carrier_id, o.external_payment_type_id, COALESCE(oi_agg.items_count, 0) AS items_count, COALESCE(oi_agg.items_qty, 0) AS items_qty, COALESCE(oi_agg.projects_done, 0) AS projects_done, COALESCE(oi_agg.projects_total, 0) AS projects_total, COALESCE(sh_agg.shipments_count, 0) AS shipments_count, COALESCE(od_agg.documents_count, 0) AS documents_count, ig.name AS integration_name, ' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count FROM orders o LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code LEFT JOIN integrations ig ON ig.id = o.integration_id LEFT JOIN ( SELECT order_id, COUNT(*) AS items_count, COALESCE(SUM(quantity), 0) AS items_qty, SUM(CASE WHEN project_generated = 1 THEN 1 ELSE 0 END) AS projects_done, COUNT(*) AS projects_total FROM order_items GROUP BY order_id ) oi_agg ON oi_agg.order_id = o.id LEFT JOIN ( SELECT order_id, COUNT(*) AS shipments_count FROM order_shipments GROUP BY order_id ) sh_agg ON sh_agg.order_id = o.id LEFT JOIN ( SELECT order_id, COUNT(*) AS documents_count FROM order_documents GROUP BY order_id ) od_agg ON od_agg.order_id = o.id' . $whereSql . ' ORDER BY ' . $sortColumn . ' ' . $sortDir . ' LIMIT :limit OFFSET :offset'; } /** * @param array $row * @param array> $itemPreviewsByOrderId * @return array */ private function transformOrderRow(array $row, array $itemPreviewsByOrderId): array { $orderId = (int) ($row['id'] ?? 0); return [ 'id' => $orderId, 'internal_order_number' => (string) ($row['internal_order_number'] ?? ''), 'source' => (string) ($row['source'] ?? ''), 'source_order_id' => (string) ($row['source_order_id'] ?? ''), 'external_order_id' => (string) ($row['external_order_id'] ?? ''), 'status_code' => (string) ($row['status_code'] ?? ''), 'effective_status_id' => (string) ($row['effective_status_id'] ?? ''), 'payment_status' => isset($row['payment_status']) ? (int) $row['payment_status'] : null, 'currency' => (string) ($row['currency'] ?? ''), 'total_with_tax' => $row['total_with_tax'] !== null ? (float) $row['total_with_tax'] : null, 'total_paid' => $row['total_paid'] !== null ? (float) $row['total_paid'] : null, 'ordered_at' => (string) ($row['effective_ordered_at'] ?? ''), 'source_created_at' => (string) ($row['source_created_at'] ?? ''), 'source_updated_at' => (string) ($row['source_updated_at'] ?? ''), 'fetched_at' => (string) ($row['fetched_at'] ?? ''), 'is_invoice' => (int) ($row['is_invoice'] ?? 0) === 1, 'is_canceled_by_buyer' => (int) ($row['is_canceled_by_buyer'] ?? 0) === 1, 'external_carrier_id' => (string) ($row['external_carrier_id'] ?? ''), 'external_payment_type_id' => (string) ($row['external_payment_type_id'] ?? ''), 'buyer_name' => (string) ($row['buyer_name'] ?? ''), 'buyer_email' => (string) ($row['buyer_email'] ?? ''), 'buyer_city' => (string) ($row['buyer_city'] ?? ''), 'items_count' => (int) ($row['items_count'] ?? 0), 'items_qty' => (float) ($row['items_qty'] ?? 0), 'shipments_count' => (int) ($row['shipments_count'] ?? 0), 'documents_count' => (int) ($row['documents_count'] ?? 0), 'integration_name' => (string) ($row['integration_name'] ?? ''), 'items_preview' => (array) ($itemPreviewsByOrderId[$orderId] ?? []), 'projects_done' => (int) ($row['projects_done'] ?? 0), 'projects_total' => (int) ($row['projects_total'] ?? 0), 'customer_returned_count' => max(0, (int) ($row['customer_returned_count'] ?? 0)), ]; } /** * @return array */ public function statusOptions(): array { try { $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm'); $rows = $this->pdo->query( 'SELECT DISTINCT ' . $effectiveStatusSql . ' AS effective_status_id FROM orders o LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code WHERE ' . $effectiveStatusSql . ' IS NOT NULL AND ' . $effectiveStatusSql . ' <> "" ORDER BY effective_status_id ASC' )->fetchAll(PDO::FETCH_COLUMN); } catch (Throwable) { return []; } if (!is_array($rows)) { return []; } $options = []; foreach ($rows as $row) { $value = trim((string) $row); if ($value === '') { continue; } $options[$value] = $value; } return $options; } /** * @return array */ public function sourceOptions(): array { try { $rows = $this->pdo->query('SELECT DISTINCT source FROM orders WHERE source IS NOT NULL AND source <> "" ORDER BY source ASC')->fetchAll(PDO::FETCH_COLUMN); } catch (Throwable) { return []; } if (!is_array($rows)) { return []; } $options = []; foreach ($rows as $row) { $value = trim((string) $row); if ($value === '') { continue; } $options[$value] = $value; } return $options; } /** * @return array{all:int, paid:int, shipped:int} */ public function quickStats(): array { try { $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm'); $row = $this->pdo->query('SELECT COUNT(*) AS all_count, SUM(CASE WHEN payment_status = 2 THEN 1 ELSE 0 END) AS paid_count, SUM(CASE WHEN ' . $effectiveStatusSql . ' IN ("shipped", "delivered", "returned") THEN 1 ELSE 0 END) AS shipped_count FROM orders o LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code')->fetch(PDO::FETCH_ASSOC); } catch (Throwable) { return [ 'all' => 0, 'paid' => 0, 'shipped' => 0, ]; } if (!is_array($row)) { return [ 'all' => 0, 'paid' => 0, 'shipped' => 0, ]; } return [ 'all' => (int) ($row['all_count'] ?? 0), 'paid' => (int) ($row['paid_count'] ?? 0), 'shipped' => (int) ($row['shipped_count'] ?? 0), ]; } /** * @return array */ public function statusCounts(): array { try { $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm'); $rows = $this->pdo->query( 'SELECT ' . $effectiveStatusSql . ' AS effective_status_id, COUNT(*) AS cnt FROM orders o LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code GROUP BY effective_status_id' )->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable) { return []; } if (!is_array($rows)) { return []; } $result = []; foreach ($rows as $row) { $key = trim((string) ($row['effective_status_id'] ?? '')); if ($key === '') { $key = '_empty'; } $result[$key] = (int) ($row['cnt'] ?? 0); } return $result; } /** * @return array}> */ public function statusPanelConfig(): array { try { $sql = 'SELECT g.id AS group_id, g.name AS group_name, g.color_hex AS group_color_hex, g.sort_order AS group_sort_order, s.code AS status_code, s.name AS status_name, s.sort_order AS status_sort_order FROM order_status_groups g LEFT JOIN order_statuses s ON s.group_id = g.id AND s.is_active = 1 WHERE g.is_active = 1 ORDER BY g.sort_order ASC, g.id ASC, s.sort_order ASC, s.id ASC'; $rows = $this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable) { return []; } if (!is_array($rows) || $rows === []) { return []; } $groupMap = []; foreach ($rows as $row) { $groupId = (int) ($row['group_id'] ?? 0); if ($groupId <= 0) { continue; } if (!isset($groupMap[$groupId])) { $groupMap[$groupId] = [ 'id' => $groupId, 'name' => trim((string) ($row['group_name'] ?? '')), 'color_hex' => StringHelper::normalizeColorHex((string) ($row['group_color_hex'] ?? '#64748b')), 'items' => [], ]; } $statusCode = trim((string) ($row['status_code'] ?? '')); if ($statusCode === '') { continue; } $groupMap[$groupId]['items'][] = [ 'code' => $statusCode, 'name' => trim((string) ($row['status_name'] ?? $statusCode)), ]; } return array_values($groupMap); } /** * @return list */ private function statusCodesByGroupId(int $groupId): array { try { $stmt = $this->pdo->prepare( 'SELECT code FROM order_statuses WHERE group_id = :gid AND is_active = 1 ORDER BY sort_order ASC' ); $stmt->execute(['gid' => $groupId]); $rows = $stmt->fetchAll(PDO::FETCH_COLUMN); } catch (Throwable) { return []; } if (!is_array($rows)) { return []; } $codes = []; foreach ($rows as $code) { $trimmed = strtolower(trim((string) $code)); if ($trimmed !== '') { $codes[] = $trimmed; } } return $codes; } /** * @return array|null */ public function findDetails(int $orderId): ?array { if ($orderId <= 0) { return null; } try { $effectiveStatusSql = $this->effectiveStatusSql('o', 'asm'); $orderStmt = $this->pdo->prepare( 'SELECT o.*, ' . $effectiveStatusSql . ' AS effective_status_id, ig.name AS integration_name, a.email AS buyer_email, a.phone AS buyer_phone, a.name AS buyer_name, ' . $this->customerReturnedCountSubquerySql('o', 'a') . ' AS customer_returned_count FROM orders o LEFT JOIN allegro_order_status_mappings asm ON o.source = "allegro" AND LOWER(o.status_code) = asm.allegro_status_code LEFT JOIN integrations ig ON ig.id = o.integration_id LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" WHERE o.id = :id LIMIT 1' ); $orderStmt->execute(['id' => $orderId]); $order = $orderStmt->fetch(PDO::FETCH_ASSOC); if (!is_array($order)) { return null; } $order['customer_returned_count'] = max(0, (int) ($order['customer_returned_count'] ?? 0)); return [ 'order' => $order, 'addresses' => $this->loadOrderAddresses($orderId), 'items' => $this->loadOrderItems($orderId), 'payments' => $this->loadOrderPayments($orderId), 'shipments' => $this->loadOrderShipments($orderId), 'documents' => $this->loadOrderDocuments($orderId), 'notes' => $this->loadOrderNotes($orderId), 'status_history' => $this->loadOrderStatusHistory($orderId), 'activity_log' => $this->loadActivityLog($orderId), ]; } catch (Throwable) { return null; } } /** * @return array> */ private function loadOrderAddresses(int $orderId): array { $stmt = $this->pdo->prepare('SELECT * FROM order_addresses WHERE order_id = :order_id ORDER BY FIELD(address_type, "customer", "invoice", "delivery"), id ASC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } /** * @return array> */ private function loadOrderItems(int $orderId): array { $itemsMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source'); $stmt = $this->pdo->prepare('SELECT oi.*, ' . $itemsMediaSql . ' AS resolved_media_url FROM order_items oi INNER JOIN orders o ON o.id = oi.order_id WHERE oi.order_id = :order_id ORDER BY oi.sort_order ASC, oi.id ASC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); if (!is_array($rows)) { return []; } return array_map(static function (array $row): array { $resolvedMediaUrl = trim((string) ($row['resolved_media_url'] ?? '')); if ($resolvedMediaUrl !== '') { $row['media_url'] = $resolvedMediaUrl; } unset($row['resolved_media_url']); return $row; }, $rows); } /** * @return array> */ private function loadOrderPayments(int $orderId): array { $stmt = $this->pdo->prepare('SELECT * FROM order_payments WHERE order_id = :order_id ORDER BY payment_date ASC, id ASC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } /** * @return array> */ private function loadOrderShipments(int $orderId): array { $stmt = $this->pdo->prepare('SELECT * FROM order_shipments WHERE order_id = :order_id ORDER BY posted_at ASC, id ASC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } /** * @return array> */ private function loadOrderDocuments(int $orderId): array { $stmt = $this->pdo->prepare('SELECT * FROM order_documents WHERE order_id = :order_id ORDER BY source_created_at ASC, id ASC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } /** * @return array> */ private function loadOrderNotes(int $orderId): array { $stmt = $this->pdo->prepare('SELECT * FROM order_notes WHERE order_id = :order_id ORDER BY created_at_external DESC, id DESC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } /** * @return array> */ private function loadOrderStatusHistory(int $orderId): array { $stmt = $this->pdo->prepare('SELECT * FROM order_status_history WHERE order_id = :order_id ORDER BY changed_at DESC, id DESC'); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } /** * @param array $orderIds * @return array> */ private function loadOrderItemsPreviews(array $orderIds): array { $cleanIds = []; foreach ($orderIds as $id) { $orderId = max(0, (int) $id); if ($orderId <= 0 || in_array($orderId, $cleanIds, true)) { continue; } $cleanIds[] = $orderId; } if ($cleanIds === []) { return []; } $placeholders = implode(',', array_fill(0, count($cleanIds), '?')); try { $resolvedMediaSql = $this->resolvedMediaUrlSql('oi', 'o.source'); $sql = 'SELECT oi.order_id, oi.original_name, oi.quantity, ' . $resolvedMediaSql . ' AS media_url, oi.sort_order, oi.id FROM order_items oi INNER JOIN orders o ON o.id = oi.order_id WHERE oi.order_id IN (' . $placeholders . ') ORDER BY oi.order_id ASC, oi.sort_order ASC, oi.id ASC'; $stmt = $this->pdo->prepare($sql); foreach ($cleanIds as $index => $orderId) { $stmt->bindValue($index + 1, $orderId, PDO::PARAM_INT); } $stmt->execute(); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (Throwable) { return []; } if (!is_array($rows)) { return []; } $result = []; foreach ($rows as $row) { $orderId = (int) ($row['order_id'] ?? 0); if ($orderId <= 0) { continue; } if (!isset($result[$orderId])) { $result[$orderId] = []; } if (count($result[$orderId]) >= 4) { continue; } $result[$orderId][] = [ 'name' => trim((string) ($row['original_name'] ?? '')), 'quantity' => (float) ($row['quantity'] ?? 0), 'media_url' => trim((string) ($row['media_url'] ?? '')), ]; } return $result; } /** * Subquery zliczajaca zamowienia klienta biezacego wiersza, ktore w historii * mialy paczke z delivery_status='returned' (zwrot do nadawcy). * Matching po email LUB phone (tylko cyfry, min 6) LUB name — identyczne dopasowanie * po LOWER/TRIM. Wyklucza biezace zamowienie (self-exclusion). * * Wymagania: MySQL 8.0+ (REGEXP_REPLACE). * * @param string $orderAlias alias tabeli orders w outer query (np. 'o') * @param string $addressAlias alias joina order_addresses (customer) w outer query (np. 'a') */ private function customerReturnedCountSubquerySql(string $orderAlias, string $addressAlias): string { return '(SELECT COUNT(DISTINCT sp.order_id) FROM shipment_packages sp INNER JOIN order_addresses a2 ON a2.order_id = sp.order_id AND a2.address_type = "customer" WHERE sp.delivery_status = "returned" AND sp.order_id != ' . $orderAlias . '.id AND ( (' . $addressAlias . '.email IS NOT NULL AND ' . $addressAlias . '.email <> "" AND LOWER(TRIM(a2.email)) = LOWER(TRIM(' . $addressAlias . '.email))) OR (' . $addressAlias . '.phone IS NOT NULL AND LENGTH(REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", "")) >= 6 AND REGEXP_REPLACE(a2.phone, "[^0-9]+", "") = REGEXP_REPLACE(' . $addressAlias . '.phone, "[^0-9]+", "")) OR (' . $addressAlias . '.name IS NOT NULL AND ' . $addressAlias . '.name <> "" AND LOWER(TRIM(a2.name)) = LOWER(TRIM(' . $addressAlias . '.name))) ))'; } private function effectiveStatusSql(string $orderAlias, string $mappingAlias): string { return 'CASE WHEN ' . $orderAlias . '.source = "allegro" AND ' . $mappingAlias . '.orderpro_status_code IS NOT NULL AND ' . $mappingAlias . '.orderpro_status_code <> "" THEN ' . $mappingAlias . '.orderpro_status_code ELSE ' . $orderAlias . '.status_code END'; } private function effectiveOrderedAtSql(string $orderAlias): string { return 'COALESCE(' . $orderAlias . '.ordered_at, ' . $orderAlias . '.source_created_at, ' . $orderAlias . '.source_updated_at, ' . $orderAlias . '.fetched_at' . ')'; } private function resolvedMediaUrlSql(string $itemAlias, string $sourceAlias = '"allegro"'): string { if (!$this->canResolveMappedMedia()) { return 'COALESCE(NULLIF(TRIM(' . $itemAlias . '.media_url), ""), "")'; } return 'COALESCE( NULLIF(TRIM(' . $itemAlias . '.media_url), ""), ( SELECT NULLIF(TRIM(pi.storage_path), "") FROM product_channel_map pcm INNER JOIN sales_channels sc ON sc.id = pcm.channel_id INNER JOIN product_images pi ON pi.product_id = pcm.product_id WHERE LOWER(sc.code) = LOWER(' . $sourceAlias . ') AND ( pcm.external_product_id = ' . $itemAlias . '.external_item_id OR pcm.external_product_id = ' . $itemAlias . '.source_product_id ) ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC LIMIT 1 ), "" )'; } private function canResolveMappedMedia(): bool { if (self::$supportsMappedMedia !== null) { return self::$supportsMappedMedia; } try { $requiredColumns = [ ['table' => 'product_channel_map', 'column' => 'product_id'], ['table' => 'product_channel_map', 'column' => 'channel_id'], ['table' => 'product_channel_map', 'column' => 'external_product_id'], ['table' => 'sales_channels', 'column' => 'id'], ['table' => 'sales_channels', 'column' => 'code'], ['table' => 'product_images', 'column' => 'id'], ['table' => 'product_images', 'column' => 'product_id'], ['table' => 'product_images', 'column' => 'storage_path'], ['table' => 'product_images', 'column' => 'sort_order'], ['table' => 'product_images', 'column' => 'is_main'], ]; $pairsSql = []; $params = []; foreach ($requiredColumns as $index => $required) { $tableParam = ':table_' . $index; $columnParam = ':column_' . $index; $pairsSql[] = '(TABLE_NAME = ' . $tableParam . ' AND COLUMN_NAME = ' . $columnParam . ')'; $params['table_' . $index] = $required['table']; $params['column_' . $index] = $required['column']; } $sql = 'SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND (' . implode(' OR ', $pairsSql) . ')'; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $count = (int) $stmt->fetchColumn(); self::$supportsMappedMedia = ($count === count($requiredColumns)); } catch (Throwable) { self::$supportsMappedMedia = false; } return self::$supportsMappedMedia; } /** * @return array> */ private function loadActivityLog(int $orderId): array { try { $stmt = $this->pdo->prepare( 'SELECT * FROM order_activity_log WHERE order_id = :order_id ORDER BY created_at DESC, id DESC' ); $stmt->execute(['order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } catch (Throwable) { return []; } } /** * @param array|null $details */ /** * Aktualizuje formę dostawy i/lub formę płatności zamówienia. Zapisuje wpis do activity log. * * @return bool true gdy faktycznie nastąpiła zmiana */ public function updateDeliveryAndPayment( int $orderId, ?string $deliveryMethod, ?string $paymentMethod, ?string $externalPaymentTypeId, string $actorType = 'user', ?string $actorName = null ): bool { if ($orderId <= 0) { return false; } $stmt = $this->pdo->prepare( 'SELECT id, delivery_method, payment_method, external_payment_type_id FROM orders WHERE id = :id LIMIT 1' ); $stmt->execute(['id' => $orderId]); $before = $stmt->fetch(PDO::FETCH_ASSOC); if (!is_array($before)) { return false; } $updates = []; $params = ['id' => $orderId]; $changed = []; if ($deliveryMethod !== null) { $oldValue = (string) ($before['delivery_method'] ?? ''); if ($oldValue !== $deliveryMethod) { $updates[] = 'delivery_method = :delivery_method'; $params['delivery_method'] = $deliveryMethod; $changed['delivery_method'] = ['before' => $oldValue, 'after' => $deliveryMethod]; } } if ($paymentMethod !== null) { $oldValue = (string) ($before['payment_method'] ?? ''); if ($oldValue !== $paymentMethod) { $updates[] = 'payment_method = :payment_method'; $params['payment_method'] = $paymentMethod; $changed['payment_method'] = ['before' => $oldValue, 'after' => $paymentMethod]; } } if ($externalPaymentTypeId !== null) { $oldValue = (string) ($before['external_payment_type_id'] ?? ''); if ($oldValue !== $externalPaymentTypeId) { $updates[] = 'external_payment_type_id = :external_payment_type_id'; $params['external_payment_type_id'] = $externalPaymentTypeId; $changed['external_payment_type_id'] = ['before' => $oldValue, 'after' => $externalPaymentTypeId]; } } if ($updates === []) { return false; } $updates[] = 'updated_at = NOW()'; $sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = :id'; $update = $this->pdo->prepare($sql); $update->execute($params); $summaryParts = []; if (isset($changed['delivery_method'])) { $summaryParts[] = 'forma dostawy'; } if (isset($changed['payment_method']) || isset($changed['external_payment_type_id'])) { $summaryParts[] = 'forma płatności'; } $summary = 'Zmiana danych zamówienia: ' . implode(', ', $summaryParts); $this->recordActivity( $orderId, 'details_change', $summary, $changed, $actorType, $actorName ); return true; } public function recordActivity( int $orderId, string $eventType, string $summary, ?array $details = null, string $actorType = 'system', ?string $actorName = null ): void { $stmt = $this->pdo->prepare( 'INSERT INTO order_activity_log (order_id, event_type, summary, details_json, actor_type, actor_name, created_at) VALUES (:order_id, :event_type, :summary, :details_json, :actor_type, :actor_name, NOW())' ); $stmt->execute([ 'order_id' => $orderId, 'event_type' => $eventType, 'summary' => $summary, 'details_json' => $details !== null ? json_encode($details, JSON_UNESCAPED_UNICODE) : null, 'actor_type' => $actorType, 'actor_name' => $actorName, ]); } /** * @param array $details */ public function shouldSkipDuplicateImportActivity(int $orderId, array $details): bool { if ($orderId <= 0 || !empty($details['created'])) { return false; } $sourceOrderId = trim((string) ($details['source_order_id'] ?? '')); $sourceUpdatedAt = trim((string) ($details['source_updated_at'] ?? '')); $trigger = trim((string) ($details['trigger'] ?? '')); if ($sourceOrderId === '' || $sourceUpdatedAt === '' || $trigger === '') { return false; } try { $stmt = $this->pdo->prepare( 'SELECT details_json FROM order_activity_log WHERE order_id = :order_id AND event_type = :event_type ORDER BY created_at DESC, id DESC LIMIT 1' ); $stmt->execute([ 'order_id' => $orderId, 'event_type' => 'import', ]); $lastDetailsJson = $stmt->fetchColumn(); } catch (Throwable) { return false; } if (!is_string($lastDetailsJson) || trim($lastDetailsJson) === '') { return false; } $lastDetails = json_decode($lastDetailsJson, true); if (!is_array($lastDetails) || !empty($lastDetails['created'])) { return false; } $lastSourceOrderId = trim((string) ($lastDetails['source_order_id'] ?? '')); $lastSourceUpdatedAt = trim((string) ($lastDetails['source_updated_at'] ?? '')); $lastTrigger = trim((string) ($lastDetails['trigger'] ?? '')); return $lastSourceOrderId === $sourceOrderId && $lastSourceUpdatedAt === $sourceUpdatedAt && $lastTrigger === $trigger; } public function recordStatusChange( int $orderId, ?string $fromStatus, string $toStatus, string $changeSource = 'manual', ?string $comment = null, string $actorType = 'system', ?string $actorName = null ): void { $stmt = $this->pdo->prepare( 'INSERT INTO order_status_history (order_id, from_status_id, to_status_id, changed_at, change_source, comment) VALUES (:order_id, :from_status_id, :to_status_id, NOW(), :change_source, :comment)' ); $stmt->execute([ 'order_id' => $orderId, 'from_status_id' => $fromStatus, 'to_status_id' => $toStatus, 'change_source' => $changeSource, 'comment' => $comment, ]); $fromLabel = $fromStatus !== null ? $this->resolveStatusName($fromStatus) : '-'; $toLabel = $this->resolveStatusName($toStatus); $summary = 'Zmiana statusu: ' . $fromLabel . ' → ' . $toLabel; $this->recordActivity($orderId, 'status_change', $summary, [ 'from_status' => $fromStatus, 'to_status' => $toStatus, 'change_source' => $changeSource, 'comment' => $comment, ], $actorType, $actorName); } /** * @param array $data Keys: payment_type_id, amount, payment_date, comment, currency * @return array{id:int, payment_status:int, total_paid:float}|null */ /** * @return array{source:string, integration_id:int, source_order_id:string}|null */ public function findOrderSourceInfo(int $orderId): ?array { if ($orderId <= 0) { return null; } $stmt = $this->pdo->prepare('SELECT source, integration_id, source_order_id FROM orders WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $orderId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $row : null; } /** * @param array $data Keys: payment_type_id, amount, payment_date, comment, currency * @return array{id:int, payment_status:int, total_paid:float}|null */ public function addPayment(int $orderId, array $data): ?array { if ($orderId <= 0) { return null; } $stmt = $this->pdo->prepare('SELECT id, total_with_tax, currency FROM orders WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $orderId]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!is_array($order)) { return null; } $amount = round((float) ($data['amount'] ?? 0), 2); $paymentTypeId = trim((string) ($data['payment_type_id'] ?? '')); $paymentDate = trim((string) ($data['payment_date'] ?? '')); $comment = trim((string) ($data['comment'] ?? '')); $currency = trim((string) ($data['currency'] ?? $order['currency'] ?? 'PLN')); if ($amount <= 0 || $paymentTypeId === '') { return null; } $sourcePaymentId = 'manual_' . $orderId . '_' . time(); $insert = $this->pdo->prepare( 'INSERT INTO order_payments (order_id, source_payment_id, payment_type_id, payment_date, amount, currency, comment, created_at, updated_at) VALUES (:order_id, :source_payment_id, :payment_type_id, :payment_date, :amount, :currency, :comment, NOW(), NOW())' ); $insert->execute([ 'order_id' => $orderId, 'source_payment_id' => $sourcePaymentId, 'payment_type_id' => $paymentTypeId, 'payment_date' => $paymentDate !== '' ? $paymentDate : date('Y-m-d H:i:s'), 'amount' => $amount, 'currency' => $currency, 'comment' => $comment !== '' ? $comment : null, ]); $paymentId = (int) $this->pdo->lastInsertId(); $sumStmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount), 0) FROM order_payments WHERE order_id = :order_id'); $sumStmt->execute(['order_id' => $orderId]); $totalPaid = round((float) $sumStmt->fetchColumn(), 2); $totalWithTax = $order['total_with_tax'] !== null ? (float) $order['total_with_tax'] : null; $paymentStatus = 0; if ($totalPaid > 0 && $totalWithTax !== null && $totalPaid >= $totalWithTax) { $paymentStatus = 2; } elseif ($totalPaid > 0) { $paymentStatus = 1; } $update = $this->pdo->prepare('UPDATE orders SET payment_status = :payment_status, total_paid = :total_paid, updated_at = NOW() WHERE id = :id'); $update->execute([ 'payment_status' => $paymentStatus, 'total_paid' => $totalPaid, 'id' => $orderId, ]); return [ 'id' => $paymentId, 'payment_status' => $paymentStatus, 'total_paid' => $totalPaid, ]; } public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool { try { $stmt = $this->pdo->prepare('SELECT status_code FROM orders WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $orderId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!is_array($row)) { return false; } $oldStatus = trim((string) ($row['status_code'] ?? '')); $update = $this->pdo->prepare('UPDATE orders SET status_code = :status, updated_at = NOW() WHERE id = :id'); $update->execute(['status' => $newStatusCode, 'id' => $orderId]); $this->recordStatusChange( $orderId, $oldStatus !== '' ? $oldStatus : null, $newStatusCode, 'manual', null, $actorType, $actorName ); return true; } catch (Throwable) { return false; } } private function resolveStatusName(string $code): string { $normalized = strtolower(trim($code)); if ($normalized === '') { return $code; } try { $stmt = $this->pdo->prepare('SELECT name FROM order_statuses WHERE LOWER(code) = :code LIMIT 1'); $stmt->execute(['code' => $normalized]); $name = $stmt->fetchColumn(); if (is_string($name) && trim($name) !== '') { return trim($name); } } catch (Throwable) { } return $code; } /** * @return array */ public function quickSearch(string $query, int $limit = 10): array { $query = trim($query); if ($query === '' || mb_strlen($query) < 2) { return []; } $limit = max(1, min($limit, 20)); $searchVal = '%' . $query . '%'; $sql = 'SELECT o.id, o.source_order_id, o.external_order_id, ' . 'a.name AS buyer_name, a.email AS buyer_email, a.phone AS buyer_phone ' . 'FROM orders o ' . 'LEFT JOIN order_addresses a ON a.order_id = o.id AND a.address_type = "customer" ' . 'WHERE (o.source_order_id LIKE :s1 OR o.external_order_id LIKE :s2 ' . 'OR a.name LIKE :s3 OR a.email LIKE :s4 OR a.phone LIKE :s5 ' . 'OR EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.original_name LIKE :s6)) ' . 'ORDER BY o.ordered_at DESC LIMIT :lim'; try { $stmt = $this->pdo->prepare($sql); $stmt->bindValue(':s1', $searchVal); $stmt->bindValue(':s2', $searchVal); $stmt->bindValue(':s3', $searchVal); $stmt->bindValue(':s4', $searchVal); $stmt->bindValue(':s5', $searchVal); $stmt->bindValue(':s6', $searchVal); $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); $stmt->execute(); $rows = $stmt->fetchAll(); if (!is_array($rows)) { return []; } return array_map(static function (array $row): array { $orderNumber = ((string) ($row['source_order_id'] ?? '')) !== '' ? (string) $row['source_order_id'] : (string) ($row['external_order_id'] ?? ''); return [ 'id' => (int) ($row['id'] ?? 0), 'order_number' => $orderNumber, 'buyer_name' => (string) ($row['buyer_name'] ?? ''), 'buyer_email' => (string) ($row['buyer_email'] ?? ''), 'buyer_phone' => (string) ($row['buyer_phone'] ?? ''), ]; }, $rows); } catch (Throwable) { return []; } } }