From d012a694c2155e401c9bebd8f8eacbeebb2020ae Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sun, 15 Feb 2026 16:37:57 +0100 Subject: [PATCH] ver. 0.276: ShopOrder migration, Integrations cleanup, global admin search --- admin/templates/components/table-list.php | 13 + .../order-details-custom-script.php | 432 ++++++++++++++ admin/templates/shop-order/order-details.php | 543 ++---------------- .../shop-order/order-edit-custom-script.php | 38 ++ admin/templates/shop-order/order-edit.php | 84 +-- admin/templates/shop-order/orders-list.php | 2 + admin/templates/shop-order/view-list.php | 147 ----- admin/templates/site/main-layout.php | 168 +++++- autoload/Domain/Order/OrderAdminService.php | 207 +++++++ autoload/Domain/Order/OrderRepository.php | 472 +++++++++++++++ .../admin/Controllers/SettingsController.php | 153 +++++ .../admin/Controllers/ShopOrderController.php | 323 +++++++++++ .../ShopPaymentMethodController.php | 6 +- .../Controllers/ShopStatusesController.php | 7 +- .../Controllers/ShopTransportController.php | 6 +- autoload/admin/class.Site.php | 9 + autoload/admin/controls/class.Dashboard.php | 6 +- autoload/admin/controls/class.ShopOrder.php | 133 ----- autoload/admin/controls/class.ShopProduct.php | 14 +- autoload/admin/factory/class.Integrations.php | 69 --- autoload/admin/factory/class.ShopOrder.php | 114 ---- autoload/shop/class.Order.php | 27 +- cron.php | 17 +- docs/CHANGELOG.md | 38 ++ docs/DATABASE_STRUCTURE.md | 2 + docs/PROJECT_STRUCTURE.md | 13 + docs/REFACTORING_PLAN.md | 6 +- docs/TESTING.md | 15 +- .../Unit/Domain/Order/OrderRepositoryTest.php | 94 +++ .../Controllers/ShopOrderControllerTest.php | 84 +++ updates/0.20/ver_0.276.zip | Bin 0 -> 54975 bytes updates/0.20/ver_0.276_files.txt | 4 + updates/changelog.php | 11 + updates/versions.php | 2 +- 34 files changed, 2196 insertions(+), 1063 deletions(-) create mode 100644 admin/templates/shop-order/order-details-custom-script.php create mode 100644 admin/templates/shop-order/order-edit-custom-script.php create mode 100644 admin/templates/shop-order/orders-list.php delete mode 100644 admin/templates/shop-order/view-list.php create mode 100644 autoload/Domain/Order/OrderAdminService.php create mode 100644 autoload/Domain/Order/OrderRepository.php create mode 100644 autoload/admin/Controllers/ShopOrderController.php delete mode 100644 autoload/admin/controls/class.ShopOrder.php delete mode 100644 autoload/admin/factory/class.Integrations.php delete mode 100644 autoload/admin/factory/class.ShopOrder.php create mode 100644 tests/Unit/Domain/Order/OrderRepositoryTest.php create mode 100644 tests/Unit/admin/Controllers/ShopOrderControllerTest.php create mode 100644 updates/0.20/ver_0.276.zip create mode 100644 updates/0.20/ver_0.276_files.txt diff --git a/admin/templates/components/table-list.php b/admin/templates/components/table-list.php index c6a91d4..831c9f4 100644 --- a/admin/templates/components/table-list.php +++ b/admin/templates/components/table-list.php @@ -270,6 +270,19 @@ $isCompactColumn = function(array $column): bool { diff --git a/autoload/Domain/Order/OrderAdminService.php b/autoload/Domain/Order/OrderAdminService.php new file mode 100644 index 0000000..8e11a50 --- /dev/null +++ b/autoload/Domain/Order/OrderAdminService.php @@ -0,0 +1,207 @@ +orders = $orders; + } + + public function details(int $orderId): array + { + return $this->orders->findForAdmin($orderId); + } + + public function statuses(): array + { + return $this->orders->orderStatuses(); + } + + /** + * @return array{items: array>, total: int} + */ + public function listForAdmin( + array $filters, + string $sortColumn = 'date_order', + string $sortDir = 'DESC', + int $page = 1, + int $perPage = 15 + ): array { + return $this->orders->listForAdmin($filters, $sortColumn, $sortDir, $page, $perPage); + } + + public function nextOrderId(int $orderId): ?int + { + return $this->orders->nextOrderId($orderId); + } + + public function prevOrderId(int $orderId): ?int + { + return $this->orders->prevOrderId($orderId); + } + + public function saveNotes(int $orderId, string $notes): bool + { + return $this->orders->saveNotes($orderId, $notes); + } + + public function saveOrderByAdmin(array $input): bool + { + $saved = $this->orders->saveOrderByAdmin( + (int)($input['order_id'] ?? 0), + (string)($input['client_name'] ?? ''), + (string)($input['client_surname'] ?? ''), + (string)($input['client_street'] ?? ''), + (string)($input['client_postal_code'] ?? ''), + (string)($input['client_city'] ?? ''), + (string)($input['client_email'] ?? ''), + (string)($input['firm_name'] ?? ''), + (string)($input['firm_street'] ?? ''), + (string)($input['firm_postal_code'] ?? ''), + (string)($input['firm_city'] ?? ''), + (string)($input['firm_nip'] ?? ''), + (int)($input['transport_id'] ?? 0), + (string)($input['inpost_paczkomat'] ?? ''), + (int)($input['payment_method_id'] ?? 0) + ); + + if ($saved && isset($GLOBALS['user']['id'])) { + \Log::save_log('Zamówienie zmienione przez administratora | ID: ' . (int)($input['order_id'] ?? 0), (int)$GLOBALS['user']['id']); + } + + return $saved; + } + + public function changeStatus(int $orderId, int $status, bool $sendEmail): array + { + $order = new \shop\Order($orderId); + $response = $order->update_status($status, $sendEmail ? 1 : 0); + + return is_array($response) ? $response : ['result' => false]; + } + + public function resendConfirmationEmail(int $orderId): bool + { + $order = new \shop\Order($orderId); + + return (bool)$order->order_resend_confirmation_email(); + } + + public function setOrderAsUnpaid(int $orderId): bool + { + $order = new \shop\Order($orderId); + + return (bool)$order->set_as_unpaid(); + } + + public function setOrderAsPaid(int $orderId, bool $sendMail): bool + { + $order = new \shop\Order($orderId); + if (!$order->set_as_paid()) { + return false; + } + + $order->update_status(4, $sendMail ? 1 : 0); + + return true; + } + + public function sendOrderToApilo(int $orderId): bool + { + global $mdb; + + if ($orderId <= 0) { + return false; + } + + $order = $this->orders->findForAdmin($orderId); + if (empty($order) || empty($order['apilo_order_id'])) { + return false; + } + + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); + $accessToken = $integrationsRepository -> apiloGetAccessToken(); + if (!$accessToken) { + return false; + } + + $newStatus = 8; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/status/'); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ + 'id' => (int)$order['apilo_order_id'], + 'status' => (int)\front\factory\ShopStatuses::get_apilo_status_id($newStatus), + ])); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Accept: application/json', + 'Content-Type: application/json', + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $apiloResultRaw = curl_exec($ch); + $apiloResult = json_decode((string)$apiloResultRaw, true); + + if (!is_array($apiloResult) || (int)($apiloResult['updates'] ?? 0) !== 1) { + curl_close($ch); + return false; + } + + $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_orders' AND COLUMN_NAME != 'id'"; + $columns = $mdb->query($query)->fetchAll(\PDO::FETCH_COLUMN); + $columnsList = implode(', ', $columns); + $mdb->query('INSERT INTO pp_shop_orders (' . $columnsList . ') SELECT ' . $columnsList . ' FROM pp_shop_orders pso WHERE pso.id = ' . $orderId); + $newOrderId = (int)$mdb->id(); + + $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_order_products' AND COLUMN_NAME != 'id' AND COLUMN_NAME != 'order_id'"; + $columns = $mdb->query($query)->fetchAll(\PDO::FETCH_COLUMN); + $columnsList = implode(', ', $columns); + $mdb->query('INSERT INTO pp_shop_order_products (order_id, ' . $columnsList . ') SELECT ' . $newOrderId . ', ' . $columnsList . ' FROM pp_shop_order_products psop WHERE psop.order_id = ' . $orderId); + + $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_order_statuses' AND COLUMN_NAME != 'id' AND COLUMN_NAME != 'order_id'"; + $columns = $mdb->query($query)->fetchAll(\PDO::FETCH_COLUMN); + $columnsList = implode(', ', $columns); + $mdb->query('INSERT INTO pp_shop_order_statuses (order_id, ' . $columnsList . ') SELECT ' . $newOrderId . ', ' . $columnsList . ' FROM pp_shop_order_statuses psos WHERE psos.order_id = ' . $orderId); + + $mdb->delete('pp_shop_orders', ['id' => $orderId]); + $mdb->delete('pp_shop_order_products', ['order_id' => $orderId]); + $mdb->delete('pp_shop_order_statuses', ['order_id' => $orderId]); + + $mdb->update('pp_shop_orders', ['apilo_order_id' => null], ['id' => $newOrderId]); + + curl_close($ch); + + return true; + } + + public function toggleTrustmateSend(int $orderId): array + { + $newValue = $this->orders->toggleTrustmateSend($orderId); + if ($newValue === null) { + return [ + 'result' => false, + ]; + } + + return [ + 'result' => true, + 'trustmate_send' => $newValue, + ]; + } + + public function deleteOrder(int $orderId): bool + { + $deleted = $this->orders->deleteOrder($orderId); + if ($deleted && isset($GLOBALS['user']['id'])) { + \Log::save_log('Usunięcie zamówienia | ID: ' . $orderId, (int)$GLOBALS['user']['id']); + } + + return $deleted; + } +} \ No newline at end of file diff --git a/autoload/Domain/Order/OrderRepository.php b/autoload/Domain/Order/OrderRepository.php new file mode 100644 index 0000000..fa7da85 --- /dev/null +++ b/autoload/Domain/Order/OrderRepository.php @@ -0,0 +1,472 @@ +db = $db; + } + + /** + * @return array{items: array>, total: int} + */ + public function listForAdmin( + array $filters, + string $sortColumn = 'date_order', + string $sortDir = 'DESC', + int $page = 1, + int $perPage = 15 + ): array { + $allowedSortColumns = [ + 'id' => 'q1.id', + 'number' => 'q1.number', + 'date_order' => 'q1.date_order', + 'status' => 'q1.status', + 'summary' => 'q1.summary', + 'client' => 'q1.client', + 'order_email' => 'q1.order_email', + 'client_phone' => 'q1.client_phone', + 'transport' => 'q1.transport', + 'payment_method' => 'q1.payment_method', + 'total_orders' => 'shop_order.total_orders', + 'paid' => 'q1.paid', + ]; + + $sortSql = $allowedSortColumns[$sortColumn] ?? 'q1.date_order'; + $sortDir = strtoupper(trim($sortDir)) === 'ASC' ? 'ASC' : 'DESC'; + $page = max(1, $page); + $perPage = min(self::MAX_PER_PAGE, max(1, $perPage)); + $offset = ($page - 1) * $perPage; + + $where = []; + $params = []; + + $number = $this->normalizeTextFilter($filters['number'] ?? ''); + if ($number !== '') { + $where[] = 'q1.number LIKE :number'; + $params[':number'] = '%' . $number . '%'; + } + + $dateFrom = $this->normalizeDateFilter($filters['date_from'] ?? ''); + if ($dateFrom !== null) { + $where[] = 'q1.date_order >= :date_from'; + $params[':date_from'] = $dateFrom . ' 00:00:00'; + } + + $dateTo = $this->normalizeDateFilter($filters['date_to'] ?? ''); + if ($dateTo !== null) { + $where[] = 'q1.date_order <= :date_to'; + $params[':date_to'] = $dateTo . ' 23:59:59'; + } + + $status = trim((string)($filters['status'] ?? '')); + if ($status !== '' && is_numeric($status)) { + $where[] = 'q1.status = :status'; + $params[':status'] = (int)$status; + } + + $client = $this->normalizeTextFilter($filters['client'] ?? ''); + if ($client !== '') { + $where[] = 'q1.client LIKE :client'; + $params[':client'] = '%' . $client . '%'; + } + + $address = $this->normalizeTextFilter($filters['address'] ?? ''); + if ($address !== '') { + $where[] = 'q1.address LIKE :address'; + $params[':address'] = '%' . $address . '%'; + } + + $email = $this->normalizeTextFilter($filters['order_email'] ?? ''); + if ($email !== '') { + $where[] = 'q1.order_email LIKE :order_email'; + $params[':order_email'] = '%' . $email . '%'; + } + + $phone = $this->normalizeTextFilter($filters['client_phone'] ?? ''); + if ($phone !== '') { + $where[] = 'q1.client_phone LIKE :client_phone'; + $params[':client_phone'] = '%' . $phone . '%'; + } + + $transport = $this->normalizeTextFilter($filters['transport'] ?? ''); + if ($transport !== '') { + $where[] = 'q1.transport LIKE :transport'; + $params[':transport'] = '%' . $transport . '%'; + } + + $payment = $this->normalizeTextFilter($filters['payment_method'] ?? ''); + if ($payment !== '') { + $where[] = 'q1.payment_method LIKE :payment_method'; + $params[':payment_method'] = '%' . $payment . '%'; + } + + $whereSql = ''; + if (!empty($where)) { + $whereSql = ' WHERE ' . implode(' AND ', $where); + } + + $baseSql = " + FROM ( + SELECT + id, + number, + date_order, + CONCAT(client_name, ' ', client_surname) AS client, + client_email AS order_email, + CONCAT(client_street, ', ', client_postal_code, ' ', client_city) AS address, + status, + client_phone, + transport, + payment_method, + summary, + paid + FROM pp_shop_orders AS pso + ) AS q1 + LEFT JOIN ( + SELECT + client_email, + COUNT(*) AS total_orders + FROM pp_shop_orders + WHERE client_name IS NOT NULL AND client_surname IS NOT NULL AND client_email IS NOT NULL + GROUP BY client_email + ) AS shop_order ON q1.order_email = shop_order.client_email + "; + + $sqlCount = 'SELECT COUNT(0) ' . $baseSql . $whereSql; + $stmtCount = $this->db->query($sqlCount, $params); + $countRows = $stmtCount ? $stmtCount->fetchAll() : []; + $total = 0; + if (is_array($countRows) && isset($countRows[0]) && is_array($countRows[0])) { + $firstRow = $countRows[0]; + $firstValue = reset($firstRow); + $total = $firstValue !== false ? (int)$firstValue : 0; + } + + $sql = ' + SELECT + q1.*, + COALESCE(shop_order.total_orders, 0) AS total_orders + ' + . $baseSql + . $whereSql + . ' ORDER BY ' . $sortSql . ' ' . $sortDir . ', q1.id DESC' + . ' LIMIT ' . $perPage . ' OFFSET ' . $offset; + + $stmt = $this->db->query($sql, $params); + if (!$stmt) { + return [ + 'items' => [], + 'total' => $total, + ]; + } + + $items = $stmt ? $stmt->fetchAll() : []; + if (!is_array($items)) { + $items = []; + } + + foreach ($items as &$item) { + $item['id'] = (int)($item['id'] ?? 0); + $item['status'] = (int)($item['status'] ?? 0); + $item['paid'] = (int)($item['paid'] ?? 0); + $item['summary'] = (float)($item['summary'] ?? 0); + $item['total_orders'] = (int)($item['total_orders'] ?? 0); + $item['number'] = (string)($item['number'] ?? ''); + $item['date_order'] = (string)($item['date_order'] ?? ''); + $item['client'] = trim((string)($item['client'] ?? '')); + $item['order_email'] = (string)($item['order_email'] ?? ''); + $item['address'] = trim((string)($item['address'] ?? '')); + $item['client_phone'] = (string)($item['client_phone'] ?? ''); + $item['transport'] = (string)($item['transport'] ?? ''); + $item['payment_method'] = (string)($item['payment_method'] ?? ''); + } + unset($item); + + return [ + 'items' => $items, + 'total' => $total, + ]; + } + + public function findForAdmin(int $orderId): array + { + if ($orderId <= 0) { + return []; + } + + $order = $this->db->get('pp_shop_orders', '*', ['id' => $orderId]); + if (!is_array($order)) { + return []; + } + + $order['id'] = (int)($order['id'] ?? 0); + $order['status'] = (int)($order['status'] ?? 0); + $order['paid'] = (int)($order['paid'] ?? 0); + $order['summary'] = (float)($order['summary'] ?? 0); + $order['transport_cost'] = (float)($order['transport_cost'] ?? 0); + $order['products'] = $this->orderProducts($orderId); + $order['statuses'] = $this->orderStatusHistory($orderId); + + return $order; + } + + public function orderProducts(int $orderId): array + { + if ($orderId <= 0) { + return []; + } + + $rows = $this->db->select('pp_shop_order_products', '*', [ + 'order_id' => $orderId, + ]); + + return is_array($rows) ? $rows : []; + } + + public function orderStatusHistory(int $orderId): array + { + if ($orderId <= 0) { + return []; + } + + $rows = $this->db->select('pp_shop_order_statuses', '*', [ + 'order_id' => $orderId, + 'ORDER' => ['id' => 'DESC'], + ]); + + return is_array($rows) ? $rows : []; + } + + public function orderStatuses(): array + { + $rows = $this->db->select('pp_shop_statuses', ['id', 'status'], [ + 'ORDER' => ['o' => 'ASC'], + ]); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $id = (int)($row['id'] ?? 0); + if ($id < 0) { + continue; + } + + $result[$id] = (string)($row['status'] ?? ''); + } + + return $result; + } + + public function nextOrderId(int $orderId): ?int + { + if ($orderId <= 0) { + return null; + } + + $next = $this->db->get('pp_shop_orders', 'id', [ + 'id[>]' => $orderId, + 'ORDER' => ['id' => 'ASC'], + 'LIMIT' => 1, + ]); + + if (!$next) { + return null; + } + + return (int)$next; + } + + public function prevOrderId(int $orderId): ?int + { + if ($orderId <= 0) { + return null; + } + + $prev = $this->db->get('pp_shop_orders', 'id', [ + 'id[<]' => $orderId, + 'ORDER' => ['id' => 'DESC'], + 'LIMIT' => 1, + ]); + + if (!$prev) { + return null; + } + + return (int)$prev; + } + + public function saveNotes(int $orderId, string $notes): bool + { + if ($orderId <= 0) { + return false; + } + + $this->db->update('pp_shop_orders', ['notes' => $notes], ['id' => $orderId]); + + return true; + } + + public function saveOrderByAdmin( + int $orderId, + string $clientName, + string $clientSurname, + string $clientStreet, + string $clientPostalCode, + string $clientCity, + string $clientEmail, + string $firmName, + string $firmStreet, + string $firmPostalCode, + string $firmCity, + string $firmNip, + int $transportId, + string $inpostPaczkomat, + int $paymentMethodId + ): bool { + if ($orderId <= 0) { + return false; + } + + $transportName = $this->db->get('pp_shop_transports', 'name_visible', ['id' => $transportId]); + $transportCost = $this->db->get('pp_shop_transports', 'cost', ['id' => $transportId]); + $transportDescription = $this->db->get('pp_shop_transports', 'description', ['id' => $transportId]); + $paymentMethodName = $this->db->get('pp_shop_payment_methods', 'name', ['id' => $paymentMethodId]); + + $this->db->update('pp_shop_orders', [ + 'client_name' => $clientName, + 'client_surname' => $clientSurname, + 'client_street' => $clientStreet, + 'client_postal_code' => $clientPostalCode, + 'client_city' => $clientCity, + 'client_email' => $clientEmail, + 'firm_name' => $this->nullableString($firmName), + 'firm_street' => $this->nullableString($firmStreet), + 'firm_postal_code' => $this->nullableString($firmPostalCode), + 'firm_city' => $this->nullableString($firmCity), + 'firm_nip' => $this->nullableString($firmNip), + 'transport_id' => $transportId, + 'transport' => $transportName ?: null, + 'transport_cost' => $transportCost !== null ? $transportCost : 0, + 'transport_description' => $transportDescription ?: null, + 'inpost_paczkomat' => $inpostPaczkomat, + 'payment_method_id' => $paymentMethodId, + 'payment_method' => $paymentMethodName ?: null, + ], [ + 'id' => $orderId, + ]); + + $this->db->update('pp_shop_orders', [ + 'summary' => $this->calculateOrderSummaryByAdmin($orderId), + ], [ + 'id' => $orderId, + ]); + + return true; + } + + public function calculateOrderSummaryByAdmin(int $orderId): float + { + if ($orderId <= 0) { + return 0.0; + } + + $rows = $this->db->select('pp_shop_order_products', [ + 'price_brutto', + 'price_brutto_promo', + 'quantity', + ], [ + 'order_id' => $orderId, + ]); + + $summary = 0.0; + if (is_array($rows)) { + foreach ($rows as $row) { + $quantity = (float)($row['quantity'] ?? 0); + $pricePromo = (float)($row['price_brutto_promo'] ?? 0); + $price = (float)($row['price_brutto'] ?? 0); + + if ($pricePromo > 0) { + $summary += $pricePromo * $quantity; + } else { + $summary += $price * $quantity; + } + } + } + + $transportCost = (float)$this->db->get('pp_shop_orders', 'transport_cost', ['id' => $orderId]); + + return (float)$summary + $transportCost; + } + + public function toggleTrustmateSend(int $orderId): ?int + { + if ($orderId <= 0) { + return null; + } + + $order = $this->db->get('pp_shop_orders', ['trustmate_send'], ['id' => $orderId]); + if (!is_array($order)) { + return null; + } + + $newValue = ((int)($order['trustmate_send'] ?? 0) === 1) ? 0 : 1; + $this->db->update('pp_shop_orders', ['trustmate_send' => $newValue], ['id' => $orderId]); + + return $newValue; + } + + public function deleteOrder(int $orderId): bool + { + if ($orderId <= 0) { + return false; + } + + $this->db->delete('pp_shop_orders', ['id' => $orderId]); + + return true; + } + + private function nullableString(string $value): ?string + { + $value = trim($value); + + return $value === '' ? null : $value; + } + + private function normalizeTextFilter($value): string + { + $value = trim((string)$value); + if ($value === '') { + return ''; + } + + if (strlen($value) > 255) { + return substr($value, 0, 255); + } + + return $value; + } + + private function normalizeDateFilter($value): ?string + { + $value = trim((string)$value); + if ($value === '') { + return null; + } + + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return null; + } + + return $value; + } +} \ No newline at end of file diff --git a/autoload/admin/Controllers/SettingsController.php b/autoload/admin/Controllers/SettingsController.php index 35b65c0..3fb7d36 100644 --- a/autoload/admin/Controllers/SettingsController.php +++ b/autoload/admin/Controllers/SettingsController.php @@ -68,6 +68,159 @@ class SettingsController exit; } + /** + * Globalna wyszukiwarka admin (produkty + zamowienia) - AJAX. + */ + public function globalSearchAjax(): void + { + global $mdb; + + $phrase = trim((string)\S::get('q')); + if ($phrase === '' || mb_strlen($phrase) < 2) { + echo json_encode([ + 'status' => 'ok', + 'items' => [], + ]); + exit; + } + + $phrase = mb_substr($phrase, 0, 120); + $phraseNormalized = preg_replace('/\s+/', ' ', $phrase); + $phraseNormalized = trim((string)$phraseNormalized); + $like = '%' . $phrase . '%'; + $likeNormalized = '%' . $phraseNormalized . '%'; + + $items = []; + $defaultLang = (string)\front\factory\Languages::default_language(); + + try { + $productStmt = $mdb->query( + 'SELECT ' + . 'p.id, p.ean, p.sku, p.parent_id, psl.name ' + . 'FROM pp_shop_products AS p ' + . 'LEFT JOIN pp_shop_products_langs AS psl ON psl.product_id = p.id AND psl.lang_id = :lang_id ' + . 'WHERE ' + . '(p.ean LIKE :q1 OR p.sku LIKE :q2 OR psl.name LIKE :q3) ' + . 'AND p.archive != 1 ' + . 'ORDER BY p.id DESC ' + . 'LIMIT 15', + [ + ':lang_id' => $defaultLang, + ':q1' => $like, + ':q2' => $like, + ':q3' => $like, + ] + ); + } catch (\Throwable $e) { + $productStmt = false; + } + + $productRows = $productStmt ? $productStmt->fetchAll() : []; + if (is_array($productRows)) { + foreach ($productRows as $row) { + $productId = (int)($row['id'] ?? 0); + if ($productId <= 0) { + continue; + } + + $name = trim((string)($row['name'] ?? '')); + if ($name === '') { + $name = 'Produkt #' . $productId; + } + + $meta = []; + $sku = trim((string)($row['sku'] ?? '')); + $ean = trim((string)($row['ean'] ?? '')); + if ($sku !== '') { + $meta[] = 'SKU: ' . $sku; + } + if ($ean !== '') { + $meta[] = 'EAN: ' . $ean; + } + + $items[] = [ + 'type' => 'product', + 'title' => $name, + 'subtitle' => implode(' | ', $meta), + 'url' => '/admin/shop_product/product_edit/id=' . $productId, + ]; + } + } + + try { + $orderStmt = $mdb->query( + 'SELECT ' + . 'id, number, client_name, client_surname, client_email, client_phone ' + . 'FROM pp_shop_orders ' + . 'WHERE ' + . '(' + . 'number LIKE :q1 ' + . 'OR client_email LIKE :q2 ' + . 'OR client_name LIKE :q3 ' + . 'OR client_surname LIKE :q4 ' + . 'OR client_phone LIKE :q5 ' + . "OR CONCAT_WS(' ', TRIM(client_name), TRIM(client_surname)) LIKE :q6 " + . "OR CONCAT_WS(' ', TRIM(client_surname), TRIM(client_name)) LIKE :q7 " + . ') ' + . 'ORDER BY id DESC ' + . 'LIMIT 15', + [ + ':q1' => $like, + ':q2' => $like, + ':q3' => $like, + ':q4' => $like, + ':q5' => $like, + ':q6' => $likeNormalized, + ':q7' => $likeNormalized, + ] + ); + } catch (\Throwable $e) { + $orderStmt = false; + } + + $orderRows = $orderStmt ? $orderStmt->fetchAll() : []; + if (is_array($orderRows)) { + foreach ($orderRows as $row) { + $orderId = (int)($row['id'] ?? 0); + if ($orderId <= 0) { + continue; + } + + $orderNumber = trim((string)($row['number'] ?? '')); + $clientName = trim((string)($row['client_name'] ?? '')); + $clientSurname = trim((string)($row['client_surname'] ?? '')); + $clientEmail = trim((string)($row['client_email'] ?? '')); + $clientPhone = trim((string)($row['client_phone'] ?? '')); + + $title = $orderNumber !== '' ? 'Zamówienie ' . $orderNumber : 'Zamówienie #' . $orderId; + $subtitleParts = []; + $fullName = trim($clientName . ' ' . $clientSurname); + if ($fullName !== '') { + $subtitleParts[] = $fullName; + } + if ($clientEmail !== '') { + $subtitleParts[] = $clientEmail; + } + if ($clientPhone !== '') { + $subtitleParts[] = $clientPhone; + } + + $items[] = [ + 'type' => 'order', + 'title' => $title, + 'subtitle' => implode(' | ', $subtitleParts), + 'url' => '/admin/shop_order/order_details/order_id=' . $orderId, + ]; + } + } + + echo json_encode([ + 'status' => 'ok', + 'items' => array_slice($items, 0, 20), + ]); + exit; + } + /** * Zapis ustawien (AJAX). */ diff --git a/autoload/admin/Controllers/ShopOrderController.php b/autoload/admin/Controllers/ShopOrderController.php new file mode 100644 index 0000000..207f4a9 --- /dev/null +++ b/autoload/admin/Controllers/ShopOrderController.php @@ -0,0 +1,323 @@ +service = $service; + } + + public function list(): string + { + return $this->view_list(); + } + + public function view_list(): string + { + $sortableColumns = [ + 'number', + 'date_order', + 'status', + 'summary', + 'client', + 'order_email', + 'client_phone', + 'transport', + 'payment_method', + 'total_orders', + 'paid', + ]; + + $statusOptions = ['' => '- status -']; + foreach ($this->service->statuses() as $statusId => $statusName) { + $statusOptions[(string)$statusId] = (string)$statusName; + } + + $filterDefinitions = [ + ['key' => 'number', 'label' => 'Nr zamówienia', 'type' => 'text'], + ['key' => 'date_from', 'label' => 'Data od', 'type' => 'date'], + ['key' => 'date_to', 'label' => 'Data do', 'type' => 'date'], + ['key' => 'status', 'label' => 'Status', 'type' => 'select', 'options' => $statusOptions], + ['key' => 'client', 'label' => 'Klient', 'type' => 'text'], + ['key' => 'address', 'label' => 'Adres', 'type' => 'text'], + ['key' => 'order_email', 'label' => 'Email', 'type' => 'text'], + ['key' => 'client_phone', 'label' => 'Telefon', 'type' => 'text'], + ['key' => 'transport', 'label' => 'Dostawa', 'type' => 'text'], + ['key' => 'payment_method', 'label' => 'Płatność', 'type' => 'text'], + ]; + + $listRequest = \admin\Support\TableListRequestFactory::fromRequest( + $filterDefinitions, + $sortableColumns, + 'date_order' + ); + + $result = $this->service->listForAdmin( + $listRequest['filters'], + $listRequest['sortColumn'], + $listRequest['sortDir'], + $listRequest['page'], + $listRequest['perPage'] + ); + + $statusesMap = $this->service->statuses(); + $rows = []; + $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1; + + foreach ($result['items'] as $item) { + $orderId = (int)($item['id'] ?? 0); + $orderNumber = (string)($item['number'] ?? ''); + $statusId = (int)($item['status'] ?? 0); + $statusLabel = (string)($statusesMap[$statusId] ?? ('Status #' . $statusId)); + + $rows[] = [ + 'lp' => $lp++ . '.', + 'date_order' => $this->formatDateTime((string)($item['date_order'] ?? '')), + 'number' => '' . htmlspecialchars($orderNumber, ENT_QUOTES, 'UTF-8') . '', + 'paid' => ((int)($item['paid'] ?? 0) === 1) + ? '' + : '', + 'status' => htmlspecialchars($statusLabel, ENT_QUOTES, 'UTF-8'), + 'summary' => number_format((float)($item['summary'] ?? 0), 2, '.', ' ') . ' zł', + 'client' => htmlspecialchars((string)($item['client'] ?? ''), ENT_QUOTES, 'UTF-8') . ' | zamówienia: ' . (int)($item['total_orders'] ?? 0) . '', + 'address' => (string)($item['address'] ?? ''), + 'order_email' => (string)($item['order_email'] ?? ''), + 'client_phone' => (string)($item['client_phone'] ?? ''), + 'transport' => (string)($item['transport'] ?? ''), + 'payment_method' => (string)($item['payment_method'] ?? ''), + '_actions' => [ + [ + 'label' => 'Szczegóły', + 'url' => '/admin/shop_order/order_details/order_id=' . $orderId, + 'class' => 'btn btn-xs btn-primary', + ], + [ + 'label' => 'Usuń', + 'url' => '/admin/shop_order/order_delete/id=' . $orderId, + 'class' => 'btn btn-xs btn-danger', + 'confirm' => 'Na pewno chcesz usunąć wybrane zamówienie?', + 'confirm_ok' => 'Usuń', + 'confirm_cancel' => 'Anuluj', + ], + ], + ]; + } + + $total = (int)$result['total']; + $totalPages = max(1, (int)ceil($total / $listRequest['perPage'])); + + $viewModel = new PaginatedTableViewModel( + [ + ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false], + ['key' => 'date_order', 'sort_key' => 'date_order', 'label' => 'Data dodania', 'class' => 'text-center', 'sortable' => true], + ['key' => 'number', 'sort_key' => 'number', 'label' => 'Nr zamówienia', 'class' => 'text-center', 'sortable' => true, 'raw' => true], + ['key' => 'paid', 'sort_key' => 'paid', 'label' => '', 'class' => 'text-center', 'sortable' => true, 'raw' => true], + ['key' => 'status', 'sort_key' => 'status', 'label' => 'Status', 'sortable' => true, 'raw' => true], + ['key' => 'summary', 'sort_key' => 'summary', 'label' => 'Wartość', 'class' => 'text-right align-middle', 'sortable' => true], + ['key' => 'client', 'sort_key' => 'client', 'label' => 'Klient', 'sortable' => true, 'raw' => true], + ['key' => 'address', 'label' => 'Adres', 'sortable' => false], + ['key' => 'order_email', 'sort_key' => 'order_email', 'label' => 'Email', 'sortable' => true], + ['key' => 'client_phone', 'sort_key' => 'client_phone', 'label' => 'Telefon', 'sortable' => true], + ['key' => 'transport', 'sort_key' => 'transport', 'label' => 'Dostawa', 'sortable' => true], + ['key' => 'payment_method', 'sort_key' => 'payment_method', 'label' => 'Płatność', 'sortable' => true], + ], + $rows, + $listRequest['viewFilters'], + [ + 'column' => $listRequest['sortColumn'], + 'dir' => $listRequest['sortDir'], + ], + [ + 'page' => $listRequest['page'], + 'per_page' => $listRequest['perPage'], + 'total' => $total, + 'total_pages' => $totalPages, + ], + array_merge($listRequest['queryFilters'], [ + 'sort' => $listRequest['sortColumn'], + 'dir' => $listRequest['sortDir'], + 'per_page' => $listRequest['perPage'], + ]), + $listRequest['perPageOptions'], + $sortableColumns, + '/admin/shop_order/list/', + 'Brak danych w tabeli.' + ); + + return \Tpl::view('shop-order/orders-list', [ + 'viewModel' => $viewModel, + ]); + } + + public function details(): string + { + return $this->order_details(); + } + + public function order_details(): string + { + $orderId = (int)\S::get('order_id'); + $order = $this->service->details($orderId); + + $coupon = null; + if (!empty($order) && !empty($order['coupon_id'])) { + $coupon = new \shop\Coupon((int)$order['coupon_id']); + } + + return \Tpl::view('shop-order/order-details', [ + 'order' => $order, + 'coupon' => $coupon, + 'order_statuses' => $this->service->statuses(), + 'next_order_id' => $this->service->nextOrderId($orderId), + 'prev_order_id' => $this->service->prevOrderId($orderId), + ]); + } + + public function edit(): string + { + return $this->order_edit(); + } + + public function order_edit(): string + { + $orderId = (int)\S::get('order_id'); + + return \Tpl::view('shop-order/order-edit', [ + 'order' => $this->service->details($orderId), + 'order_statuses' => $this->service->statuses(), + 'transport' => \shop\Transport::transport_list(), + 'payment_methods' => \shop\PaymentMethod::method_list(), + ]); + } + + public function save(): void + { + $this->order_save(); + } + + public function order_save(): void + { + $saved = $this->service->saveOrderByAdmin([ + 'order_id' => (int)\S::get('order_id'), + 'client_name' => (string)\S::get('client_name'), + 'client_surname' => (string)\S::get('client_surname'), + 'client_street' => (string)\S::get('client_street'), + 'client_postal_code' => (string)\S::get('client_postal_code'), + 'client_city' => (string)\S::get('client_city'), + 'client_email' => (string)\S::get('client_email'), + 'firm_name' => (string)\S::get('firm_name'), + 'firm_street' => (string)\S::get('firm_street'), + 'firm_postal_code' => (string)\S::get('firm_postal_code'), + 'firm_city' => (string)\S::get('firm_city'), + 'firm_nip' => (string)\S::get('firm_nip'), + 'transport_id' => (int)\S::get('transport_id'), + 'inpost_paczkomat' => (string)\S::get('inpost_paczkomat'), + 'payment_method_id' => (int)\S::get('payment_method_id'), + ]); + + if ($saved) { + \S::alert('Zamówienie zostało zapisane.'); + } + + header('Location: /admin/shop_order/order_details/order_id=' . (int)\S::get('order_id')); + exit; + } + + public function notes_save(): void + { + $this->service->saveNotes((int)\S::get('order_id'), (string)\S::get('notes')); + } + + public function order_status_change(): void + { + $response = $this->service->changeStatus( + (int)\S::get('order_id'), + (int)\S::get('status'), + (string)\S::get('email') === 'true' + ); + + echo json_encode($response); + exit; + } + + public function order_resend_confirmation_email(): void + { + $response = $this->service->resendConfirmationEmail((int)\S::get('order_id')); + + echo json_encode(['result' => $response]); + exit; + } + + public function set_order_as_unpaid(): void + { + $orderId = (int)\S::get('order_id'); + $this->service->setOrderAsUnpaid($orderId); + + header('Location: /admin/shop_order/order_details/order_id=' . $orderId); + exit; + } + + public function set_order_as_paid(): void + { + $orderId = (int)\S::get('order_id'); + $this->service->setOrderAsPaid($orderId, (int)\S::get('send_mail') === 1); + + header('Location: /admin/shop_order/order_details/order_id=' . $orderId); + exit; + } + + public function send_order_to_apilo(): void + { + $orderId = (int)\S::get('order_id'); + + if ($this->service->sendOrderToApilo($orderId)) { + \S::alert('Zamówienie zostanie wysłane ponownie do apilo.com'); + } else { + \S::alert('Wystąpił błąd podczas wysyłania zamówienia do apilo.com'); + } + + header('Location: /admin/shop_order/order_details/order_id=' . $orderId); + exit; + } + + public function toggle_trustmate_send(): void + { + echo json_encode($this->service->toggleTrustmateSend((int)\S::get('order_id'))); + exit; + } + + public function delete(): void + { + $this->order_delete(); + } + + public function order_delete(): void + { + if ($this->service->deleteOrder((int)\S::get('id'))) { + \S::alert('Zamówienie zostało usunięte'); + } + + header('Location: /admin/shop_order/list/'); + exit; + } + + private function formatDateTime(string $value): string + { + if ($value === '') { + return ''; + } + + $ts = strtotime($value); + if ($ts === false) { + return $value; + } + + return date('Y-m-d H:i', $ts); + } +} \ No newline at end of file diff --git a/autoload/admin/Controllers/ShopPaymentMethodController.php b/autoload/admin/Controllers/ShopPaymentMethodController.php index e72e07a..6ff4352 100644 --- a/autoload/admin/Controllers/ShopPaymentMethodController.php +++ b/autoload/admin/Controllers/ShopPaymentMethodController.php @@ -2,6 +2,7 @@ namespace admin\Controllers; use Domain\PaymentMethod\PaymentMethodRepository; +use Domain\Integrations\IntegrationsRepository; use admin\ViewModels\Common\PaginatedTableViewModel; use admin\ViewModels\Forms\FormAction; use admin\ViewModels\Forms\FormEditViewModel; @@ -240,7 +241,10 @@ class ShopPaymentMethodController private function getApiloPaymentTypes(): array { - $rawSetting = \admin\factory\Integrations::apilo_settings('payment-types-list'); + global $mdb; + + $integrationsRepository = new IntegrationsRepository( $mdb ); + $rawSetting = $integrationsRepository -> getSetting( 'apilo', 'payment-types-list' ); $raw = null; if (is_array($rawSetting)) { diff --git a/autoload/admin/Controllers/ShopStatusesController.php b/autoload/admin/Controllers/ShopStatusesController.php index 1845cfe..22ac7c3 100644 --- a/autoload/admin/Controllers/ShopStatusesController.php +++ b/autoload/admin/Controllers/ShopStatusesController.php @@ -2,6 +2,7 @@ namespace admin\Controllers; use Domain\ShopStatus\ShopStatusRepository; +use Domain\Integrations\IntegrationsRepository; use admin\ViewModels\Common\PaginatedTableViewModel; use admin\ViewModels\Forms\FormAction; use admin\ViewModels\Forms\FormEditViewModel; @@ -246,8 +247,12 @@ class ShopStatusesController private function getApiloStatusList(): array { + global $mdb; + + $integrationsRepository = new IntegrationsRepository( $mdb ); + $list = []; - $raw = @unserialize(\admin\factory\Integrations::apilo_settings('status-types-list')); + $raw = @unserialize( $integrationsRepository -> getSetting( 'apilo', 'status-types-list' ) ); if (is_array($raw)) { foreach ($raw as $apiloStatus) { if (isset($apiloStatus['id'], $apiloStatus['name'])) { diff --git a/autoload/admin/Controllers/ShopTransportController.php b/autoload/admin/Controllers/ShopTransportController.php index 65fc5ae..ebe160a 100644 --- a/autoload/admin/Controllers/ShopTransportController.php +++ b/autoload/admin/Controllers/ShopTransportController.php @@ -3,6 +3,7 @@ namespace admin\Controllers; use Domain\Transport\TransportRepository; use Domain\PaymentMethod\PaymentMethodRepository; +use Domain\Integrations\IntegrationsRepository; use admin\ViewModels\Common\PaginatedTableViewModel; use admin\ViewModels\Forms\FormAction; use admin\ViewModels\Forms\FormEditViewModel; @@ -314,7 +315,10 @@ class ShopTransportController private function getApiloCarrierAccounts(): array { - $rawSetting = \admin\factory\Integrations::apilo_settings('carrier-account-list'); + global $mdb; + + $integrationsRepository = new IntegrationsRepository( $mdb ); + $rawSetting = $integrationsRepository -> getSetting( 'apilo', 'carrier-account-list' ); $raw = null; if (is_array($rawSetting)) { diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php index 2d6c1ad..119f4c9 100644 --- a/autoload/admin/class.Site.php +++ b/autoload/admin/class.Site.php @@ -399,6 +399,15 @@ class Site new \Domain\Client\ClientRepository( $mdb ) ); }, + 'ShopOrder' => function() { + global $mdb; + + return new \admin\Controllers\ShopOrderController( + new \Domain\Order\OrderAdminService( + new \Domain\Order\OrderRepository( $mdb ) + ) + ); + }, ]; return self::$newControllers; diff --git a/autoload/admin/controls/class.Dashboard.php b/autoload/admin/controls/class.Dashboard.php index fbd00d1..b8f2043 100644 --- a/autoload/admin/controls/class.Dashboard.php +++ b/autoload/admin/controls/class.Dashboard.php @@ -4,9 +4,13 @@ class Dashboard { static public function main_view() { + global $mdb; + + $statusesRepository = new \Domain\ShopStatus\ShopStatusRepository( $mdb ); + return \Tpl::view( 'dashboard/main-view', [ 'last_orders' => \shop\Dashboard::last_orders(), - 'order_statuses' => \shop\Order::order_statuses(), + 'order_statuses' => $statusesRepository -> allStatuses(), 'sales' => \shop\Dashboard::last_24_months_sales(), 'best_sales_products' => \shop\Dashboard::best_sales_products(), 'most_view_products' => \shop\Dashboard::most_view_products(), diff --git a/autoload/admin/controls/class.ShopOrder.php b/autoload/admin/controls/class.ShopOrder.php deleted file mode 100644 index fb0e569..0000000 --- a/autoload/admin/controls/class.ShopOrder.php +++ /dev/null @@ -1,133 +0,0 @@ - order_resend_confirmation_email(); - - echo json_encode( [ 'result' => $response ] ); - exit; - } - - static public function notes_save() - { - \shop\Order::notes_save( \S::get( 'order_id' ), \S::get( 'notes' ) ); - } - - static public function order_save() - { - if ( \shop\Order::order_save_by_admin( - \S::get( 'order_id' ), \S::get( 'client_name' ), \S::get( 'client_surname' ), \S::get( 'client_street' ), \S::get( 'client_postal_code' ), \S::get( 'client_city' ), \S::get( 'client_email' ), \S::get( 'firm_name' ), \S::get( 'firm_street' ), \S::get( 'firm_postal_code' ), \S::get( 'firm_city' ), \S::get( 'firm_nip' ), \S::get( 'transport_id' ), \S::get( 'inpost_paczkomat' ), \S::get( 'payment_method_id' ) - ) ) - \S::alert( 'Zamówienie zostało zapisane.' ); - - header( 'Location: /admin/shop_order/order_details/order_id=' . \S::get( 'order_id' ) ); - exit; - } - - static public function order_edit() - { - return \Tpl::view( 'shop-order/order-edit', [ - 'order' => new \shop\Order( (int)\S::get( 'order_id' ) ), - 'order_statuses' => \shop\Order::order_statuses(), - 'transport' => \shop\Transport::transport_list(), - 'payment_methods' => \shop\PaymentMethod::method_list() - ] ); - } - - public static function order_details() - { - $order = new \shop\Order( (int)\S::get( 'order_id' ) ); - $coupon = $order -> coupon_id ? new \shop\Coupon( $order -> coupon_id ) : null; - - return \Tpl::view( 'shop-order/order-details', [ - 'order' => $order, - 'coupon' => $coupon, - 'order_statuses' => \shop\Order::order_statuses(), - 'next_order_id' => \admin\factory\ShopOrder::next_order_id( (int)\S::get( 'order_id' ) ), - 'prev_order_id' => \admin\factory\ShopOrder::prev_order_id( (int)\S::get( 'order_id' ) ), - ] ); - } - - public static function view_list() - { - return \Tpl::view( - 'shop-order/view-list' - ); - } - - public static function order_status_change() - { - global $mdb; - - $order = new \shop\Order( (int)\S::get( 'order_id' ) ); - $response = $order -> update_status( (int)\S::get( 'status' ), \S::get( 'email' ) == 'true' ? 1 : 0 ); - - echo json_encode( $response ); - exit; - } - - public static function order_delete() - { - global $user; - - if ( \shop\Order::order_delete( (int)\S::get( 'id' ) ) ) - { - \S::alert( 'Zamówienie zostało usunięte' ); - \Log::save_log( 'Usunięcie zamówienia | ID: ' . (int)\S::get( 'id' ), $user['id'] ); - } - - header( 'Location: /admin/shop_order/view_list/' ); - exit; - } - - // set_order_as_unpaid - public static function set_order_as_unpaid() - { - $order = new \shop\Order( (int)\S::get( 'order_id' ) ); - $order -> set_as_unpaid(); - header( 'Location: /admin/shop_order/order_details/order_id=' . (int)\S::get( 'order_id' ) ); - exit; - } - - // set_order_as_paid - public static function set_order_as_paid() - { - $order = new \shop\Order( (int)\S::get( 'order_id' ) ); - if ( $order -> set_as_paid() ) - { - $order -> update_status( 4, (int)\S::get( 'send_mail' ) ); - } - header( 'Location: /admin/shop_order/order_details/order_id=' . (int)\S::get( 'order_id' ) ); - exit; - } - - // toggle_trustmate_send - public static function toggle_trustmate_send() - { - global $mdb; - - $order_id = (int)\S::get( 'order_id' ); - $order = $mdb -> get( 'pp_shop_orders', [ 'trustmate_send' ], [ 'id' => $order_id ] ); - - $new_value = $order['trustmate_send'] ? 0 : 1; - $mdb -> update( 'pp_shop_orders', [ 'trustmate_send' => $new_value ], [ 'id' => $order_id ] ); - - echo json_encode( [ 'result' => true, 'trustmate_send' => $new_value ] ); - exit; - } -} diff --git a/autoload/admin/controls/class.ShopProduct.php b/autoload/admin/controls/class.ShopProduct.php index c7113d4..3227e3c 100644 --- a/autoload/admin/controls/class.ShopProduct.php +++ b/autoload/admin/controls/class.ShopProduct.php @@ -229,6 +229,10 @@ class ShopProduct // ajax_load_products static public function ajax_load_products() { + global $mdb; + + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); + $response = [ 'status' => 'error', 'msg' => 'Podczas ładowania produktów wystąpił błąd. Proszę spróbować ponownie.' ]; \S::set_session( 'products_list_current_page', \S::get( 'current_page' ) ); @@ -241,7 +245,7 @@ class ShopProduct 'html' => \Tpl::view( 'shop-product/products-list-table', [ 'products' => $products['products'], 'current_page' => \S::get( 'current_page' ), - 'apilo_enabled' => \admin\factory\Integrations::apilo_settings( 'enabled' ), + 'apilo_enabled' => $integrationsRepository -> getSetting( 'apilo', 'enabled' ), 'show_xml_data' => \S::get_session( 'show_xml_data' ) ] ) ]; @@ -253,6 +257,10 @@ class ShopProduct static public function view_list() { + global $mdb; + + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); + $current_page = \S::get_session( 'products_list_current_page' ); if ( !$current_page ) { @@ -276,9 +284,9 @@ class ShopProduct 'current_page' => $current_page, 'query_array' => $query_array, 'pagination_max' => ceil( \admin\factory\ShopProduct::count_product() / 10 ), - 'apilo_enabled' => \admin\factory\Integrations::apilo_settings( 'enabled' ), + 'apilo_enabled' => $integrationsRepository -> getSetting( 'apilo', 'enabled' ), 'show_xml_data' => \S::get_session( 'show_xml_data' ), - 'shoppro_enabled' => \admin\factory\Integrations::shoppro_settings( 'enabled' ) + 'shoppro_enabled' => $integrationsRepository -> getSetting( 'shoppro', 'enabled' ) ] ); } diff --git a/autoload/admin/factory/class.Integrations.php b/autoload/admin/factory/class.Integrations.php deleted file mode 100644 index 7c17121..0000000 --- a/autoload/admin/factory/class.Integrations.php +++ /dev/null @@ -1,69 +0,0 @@ -getSetting( 'apilo', $name ) : $repo->getSettings( 'apilo' ); - } - - static public function apilo_settings_save( $field_id, $value ) - { - return self::repo()->saveSetting( 'apilo', $field_id, $value ); - } - - static public function apilo_get_access_token() - { - return self::repo()->apiloGetAccessToken(); - } - - static public function apilo_keepalive( int $refresh_lead_seconds = 300 ) - { - return self::repo()->apiloKeepalive( $refresh_lead_seconds ); - } - - static public function apilo_authorization( $client_id, $client_secret, $authorization_code ) - { - return self::repo()->apiloAuthorize( $client_id, $client_secret, $authorization_code ); - } - - // ── Apilo product linking ───────────────────────────────────── - - static public function apilo_product_select_save( int $product_id, $apilo_product_id, $apilo_product_name ) - { - return self::repo()->linkProduct( $product_id, $apilo_product_id, $apilo_product_name ); - } - - static public function apilo_product_select_delete( int $product_id ) - { - return self::repo()->unlinkProduct( $product_id ); - } - - // ── ShopPRO settings ────────────────────────────────────────── - - static public function shoppro_settings( $name = '' ) - { - $repo = self::repo(); - return $name ? $repo->getSetting( 'shoppro', $name ) : $repo->getSettings( 'shoppro' ); - } - - static public function shoppro_settings_save( $field_id, $value ) - { - return self::repo()->saveSetting( 'shoppro', $field_id, $value ); - } -} diff --git a/autoload/admin/factory/class.ShopOrder.php b/autoload/admin/factory/class.ShopOrder.php deleted file mode 100644 index 27f6138..0000000 --- a/autoload/admin/factory/class.ShopOrder.php +++ /dev/null @@ -1,114 +0,0 @@ - $order['apilo_order_id'], - 'status' => (int)\front\factory\ShopStatuses::get_apilo_status_id( $new_status ) - ] ) ); - curl_setopt( $ch, CURLOPT_HTTPHEADER, [ - "Authorization: Bearer " . $access_token, - "Accept: application/json", - "Content-Type: application/json" - ] ); - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true); - $apilo_result = curl_exec( $ch ); - - $apilo_result = json_decode( $apilo_result, true ); - - if ( $apilo_result['updates'] == 1 ) { - - // zmień ID zamówienia na największe ID zamówienia + 1, oraz usuń ID zamówienia z apilo - $new_order_id = $mdb -> max( 'pp_shop_orders', 'id' ) + 1; - - // pobierz listę kolumn zamówienia - $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_orders' AND COLUMN_NAME != 'id'"; - $columns = $mdb -> query( $query ) -> fetchAll( \PDO::FETCH_COLUMN ); - $columns_list = implode( ', ', $columns ); - - // kopiuj stare zamówienie do nowego ID - $mdb -> query( 'INSERT INTO pp_shop_orders (' . $columns_list . ') SELECT ' . $columns_list . ' FROM pp_shop_orders pso WHERE pso.id = ' . $order_id ); - $new_order_id = $mdb -> id(); - - // pobierz listę kolumn produktów zamówienia - $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_order_products' AND COLUMN_NAME != 'id' AND COLUMN_NAME != 'order_id'"; - $columns = $mdb -> query( $query ) -> fetchAll( \PDO::FETCH_COLUMN ); - $columns_list = implode( ', ', $columns ); - - // kopiuj produkty zamówienia do nowego zamówienia - $mdb -> query( 'INSERT INTO pp_shop_order_products (order_id, ' . $columns_list . ') SELECT ' . $new_order_id . ',' . $columns_list . ' FROM pp_shop_order_products psop WHERE psop.order_id = ' . $order_id ); - - // pobierz listę kolumn z tabeli pp_shop_order_statuses - $query = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'pp_shop_order_statuses' AND COLUMN_NAME != 'id' AND COLUMN_NAME != 'order_id'"; - $columns = $mdb -> query( $query ) -> fetchAll( \PDO::FETCH_COLUMN ); - $columns_list = implode( ', ', $columns ); - - // kopiuj statusy zamówienia do nowego zamówienia - $mdb -> query( 'INSERT INTO pp_shop_order_statuses (order_id, ' . $columns_list . ') SELECT ' . $new_order_id . ',' . $columns_list . ' FROM pp_shop_order_statuses psos WHERE psos.order_id = ' . $order_id ); - - // usuń stare zamówienie - $mdb -> delete( 'pp_shop_orders', [ 'id' => $order_id ] ); - $mdb -> delete( 'pp_shop_order_products', [ 'order_id' => $order_id ] ); - $mdb -> delete( 'pp_shop_order_statuses', [ 'order_id' => $order_id ] ); - - // zmień wartość kolumny apilo_order_id na NULL - $mdb -> update( 'pp_shop_orders', [ 'apilo_order_id' => NULL ], [ 'id' => $new_order_id ] ); - - return true; - } - - curl_close( $ch ); - } - - return false; - } - - static public function next_order_id( int $order_id ) - { - global $mdb; - - if ( !$order_id ) - return false; - - return $mdb -> get( 'pp_shop_orders', 'id', [ 'id[>]' => $order_id, 'ORDER' => [ 'id' => 'ASC' ], 'LIMIT' => 1 ] ); - } - - static public function prev_order_id( int $order_id ) - { - global $mdb; - - if ( !$order_id ) - return false; - - return $mdb -> get( 'pp_shop_orders', 'id', [ 'id[<]' => $order_id, 'ORDER' => [ 'id' => 'DESC' ], 'LIMIT' => 1 ] ); - } - - static public function order_details( int $order_id ) - { - global $mdb; - - $order = $mdb -> get( 'pp_shop_orders', '*', [ 'id' => $order_id ] ); - $order['products'] = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order_id ] ); - - return $order; - } -} diff --git a/autoload/shop/class.Order.php b/autoload/shop/class.Order.php index 251f4be..2ea5e40 100644 --- a/autoload/shop/class.Order.php +++ b/autoload/shop/class.Order.php @@ -55,10 +55,9 @@ class Order implements \ArrayAccess public static function order_statuses() { global $mdb; - $results = $mdb -> select( 'pp_shop_statuses', [ 'id', 'status' ], [ 'ORDER' => [ 'o' => 'ASC' ] ] ); - if ( \S::is_array_fix( $results ) ) foreach ( $results as $row ) - $statuses[ (int)$row['id'] ] = $row['status']; - return $statuses; + + $repository = new \Domain\ShopStatus\ShopStatusRepository( $mdb ); + return $repository -> allStatuses(); } public function update_aplio_order_status_date( $date ) @@ -80,8 +79,10 @@ class Order implements \ArrayAccess { global $mdb, $config; + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); + // apilo - $apilo_settings = \admin\factory\Integrations::apilo_settings(); + $apilo_settings = $integrationsRepository -> getSettings( 'apilo' ); if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders'] ) { // put data to file @@ -117,6 +118,8 @@ class Order implements \ArrayAccess { global $mdb, $config; + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); + if ( $this -> status == $status ) return false; @@ -137,7 +140,7 @@ class Order implements \ArrayAccess $response['result'] = true; // apilo - $apilo_settings = \admin\factory\Integrations::apilo_settings(); + $apilo_settings = $integrationsRepository -> getSettings( 'apilo' ); if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and $apilo_settings['sync_orders'] ) { // put data to file @@ -322,7 +325,9 @@ class Order implements \ArrayAccess private function sync_apilo_payment(): bool { - global $config; + global $config, $mdb; + + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); if ( !(int)$this -> apilo_order_id ) return true; @@ -332,7 +337,7 @@ class Order implements \ArrayAccess $payment_type = 1; $payment_date = new \DateTime( $this -> date_order ); - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository -> apiloGetAccessToken(); $ch = curl_init(); curl_setopt( $ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $this -> apilo_order_id . '/payment/' ); @@ -371,12 +376,14 @@ class Order implements \ArrayAccess private function sync_apilo_status( int $status ): bool { - global $config; + global $config, $mdb; + + $integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); if ( !(int)$this -> apilo_order_id ) return true; - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository -> apiloGetAccessToken(); $ch = curl_init(); curl_setopt( $ch, CURLOPT_URL, "https://projectpro.apilo.com/rest/api/orders/" . $this -> apilo_order_id . '/status/' ); diff --git a/cron.php b/cron.php index 921d385..c25e7ab 100644 --- a/cron.php +++ b/cron.php @@ -53,12 +53,13 @@ $mdb = new medoo( [ ] ); $settings = \front\factory\Settings::settings_details(); -$apilo_settings = \admin\factory\Integrations::apilo_settings(); +$integrationsRepository = new \Domain\Integrations\IntegrationsRepository( $mdb ); +$apilo_settings = $integrationsRepository -> getSettings( 'apilo' ); // Keepalive tokenu Apilo: odswiezaj token przed wygasnieciem, zeby integracja byla stale aktywna. if ( (int)($apilo_settings['enabled'] ?? 0) === 1 ) { - \admin\factory\Integrations::apilo_keepalive( 300 ); - $apilo_settings = \admin\factory\Integrations::apilo_settings(); + $integrationsRepository -> apiloKeepalive( 300 ); + $apilo_settings = $integrationsRepository -> getSettings( 'apilo' ); Order::process_apilo_sync_queue( 10 ); } @@ -122,7 +123,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_ { if ( $result = $mdb -> query( 'SELECT id, apilo_product_id, apilo_get_data_date, apilo_product_name FROM pp_shop_products WHERE apilo_product_id IS NOT NULL AND apilo_product_id != 0 AND ( apilo_get_data_date IS NULL OR apilo_get_data_date <= \'' . date( 'Y-m-d H:i:s', strtotime( '-10 minutes', time() ) ) . '\' ) ORDER BY apilo_get_data_date ASC LIMIT 1' ) -> fetch( \PDO::FETCH_ASSOC ) ) { - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository -> apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/warehouse/product/' . $result['apilo_product_id'] . '/'; $curl = curl_init( $url ); curl_setopt( $curl, CURLOPT_RETURNTRANSFER, true ); @@ -151,7 +152,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_products'] and $apilo_ // synchronizacja cen apilo.com if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and ( !$apilo_settings['pricelist_update_date'] or $apilo_settings['pricelist_update_date'] <= date( 'Y-m-d H:i:s', strtotime( '-1 hour', time() ) ) ) ) { - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository -> apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/warehouse/price-calculated/?price=' . $apilo_settings['pricelist_id']; @@ -188,7 +189,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['access-token'] and ( !$apil } } } - \admin\factory\Integrations::apilo_settings_save( 'pricelist_update_date', date( 'Y-m-d H:i:s' ) ); + $integrationsRepository -> saveSetting( 'apilo', 'pricelist_update_date', date( 'Y-m-d H:i:s' ) ); echo '

Zaktualizowałem ceny produktów (APILO)

'; } @@ -253,7 +254,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se 'media' => null ]; - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository -> apiloGetAccessToken(); $order_date = new DateTime( $order['date_order'] ); @@ -502,7 +503,7 @@ if ( $apilo_settings['enabled'] and $apilo_settings['sync_orders'] and $apilo_se { if ( $order['apilo_order_id'] ) { - $access_token = \admin\factory\Integrations::apilo_get_access_token(); + $access_token = $integrationsRepository -> apiloGetAccessToken(); $url = 'https://projectpro.apilo.com/rest/api/orders/' . $order['apilo_order_id'] . '/'; $ch = curl_init( $url ); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bde710d..58dd8d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,44 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.277 (2026-02-15) - Stabilizacja ShopOrder + Integrations + Global Search + +- **ShopOrder (stabilizacja po migracji)** + - FIX: `Domain\Order\OrderRepository::listForAdmin()` - poprawa zapytan SQL (count/list), bezpieczne fallbacki i poprawne zwracanie listy zamowien w `/admin/shop_order/list/` + - FIX: wyrównanie wysokości komórek w `components/table-list` (`vertical-align` + lokalny override dla `.text-right` w tabeli) +- **Integrations (cleanup)** + - CLEANUP: usunieta fasada `autoload/admin/factory/class.Integrations.php` + - UPDATE: przepięcie wywołań na `Domain\Integrations\IntegrationsRepository` w: `cron.php`, `shop\Order`, `admin\Controllers\ShopPaymentMethodController`, `admin\Controllers\ShopStatusesController`, `admin\Controllers\ShopTransportController`, `admin\controls\ShopProduct` +- **Admin UX** + - NOWE: globalna wyszukiwarka w top-barze (obok "Wyczysc cache") dla produktow i zamowien + - NOWE: endpoint `/admin/settings/globalSearchAjax/` (`SettingsController::globalSearchAjax`) + - FIX: wsparcie wyszukiwania po pełnym imieniu i nazwisku (np. "Jan Kowalski") + poprawka escapingu SQL w `CONCAT_WS` +- TEST: + - Pelny suite: **OK (385 tests, 1246 assertions)** + - Test punktowy: `SettingsControllerTest` **OK (7 tests, 10 assertions)** + +--- + +## ver. 0.276 (2026-02-15) - ShopOrder + +- **ShopOrder** - migracja `/admin/shop_order/*` na Domain + DI + nowe widoki + - NOWE: `Domain\Order\OrderRepository` (lista admin z filtrowaniem/sortowaniem, szczegóły, historia statusów, notes, save admin, summary, trustmate, delete) + - NOWE: `Domain\Order\OrderAdminService` (operacje aplikacyjne admin: status/paid/unpaid/resend email/send to apilo/delete) + - NOWE: `admin\Controllers\ShopOrderController` (DI) z akcjami `list/view_list`, `details/order_details`, `edit/order_edit`, `save/order_save`, `notes_save`, `order_status_change`, `order_resend_confirmation_email`, `set_order_as_paid`, `set_order_as_unpaid`, `send_order_to_apilo`, `toggle_trustmate_send`, `delete/order_delete` + - UPDATE: routing DI (`admin\Site`) rozszerzony o modul `ShopOrder` + - UPDATE: menu admin przepiete na kanoniczny URL `/admin/shop_order/list/` + - UPDATE: lista zamówień przepięta z legacy grid na `components/table-list` (`shop-order/orders-list`) + - UPDATE: `shop-order/order-details` i `shop-order/order-edit` przebudowane bez `gridEdit` + wydzielenie JS do `*-custom-script.php` + - UPDATE: `shop\Order::order_statuses()` przepiete na `Domain\ShopStatus\ShopStatusRepository` + - UPDATE: `admin\controls\Dashboard` pobiera statusy przez `Domain\ShopStatus\ShopStatusRepository` + - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopOrder.php`, `autoload/admin/factory/class.ShopOrder.php`, `admin/templates/shop-order/view-list.php` +- TEST: + - NOWE: `tests/Unit/Domain/Order/OrderRepositoryTest.php` + - NOWE: `tests/Unit/admin/Controllers/ShopOrderControllerTest.php` + - Testy punktowe: **OK (8 tests, 49 assertions)** + +--- + ## ver. 0.275 (2026-02-15) - ShopCategory - **ShopCategory** - migracja `/admin/shop_category/*` na Domain + DI + nowe endpointy AJAX diff --git a/docs/DATABASE_STRUCTURE.md b/docs/DATABASE_STRUCTURE.md index a717ee7..1d14bd2 100644 --- a/docs/DATABASE_STRUCTURE.md +++ b/docs/DATABASE_STRUCTURE.md @@ -115,6 +115,8 @@ Zamówienia sklepu (źródło danych dla list i szczegółów klientów w panelu **Aktualizacja 2026-02-15 (ver. 0.274):** moduł `/admin/shop_clients/*` korzysta z `Domain\Client\ClientRepository` przez `admin\Controllers\ShopClientsController`. +**Aktualizacja 2026-02-15 (ver. 0.276):** moduł `/admin/shop_order/*` korzysta z `Domain\Order\OrderRepository` przez `admin\Controllers\ShopOrderController`; usunięto legacy `admin\controls\ShopOrder` i `admin\factory\ShopOrder`. + ## pp_banners Banery. diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index f3de582..a25246d 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -253,6 +253,19 @@ autoload/ - `cron.php` automatycznie ponawia zalegle syncy (`Order::process_apilo_sync_queue()`). - `shop\Order::set_as_paid()` wysyla mapowany typ platnosci Apilo (z mapowania metody platnosci), bez stalej wartosci `type`. +**Aktualizacja 2026-02-15 (ver. 0.276):** +- Dodano modul domenowy `Domain/Order/OrderRepository.php`. +- Dodano serwis aplikacyjny `Domain/Order/OrderAdminService.php`. +- Dodano kontroler DI `admin/Controllers/ShopOrderController.php`. +- Modul `/admin/shop_order/*` dziala na nowych widokach (`orders-list`, `order-details`, `order-edit`). +- Usunieto legacy: `autoload/admin/controls/class.ShopOrder.php`, `autoload/admin/factory/class.ShopOrder.php`, `admin/templates/shop-order/view-list.php`. + +**Aktualizacja 2026-02-15 (ver. 0.277):** +- Dodano globalna wyszukiwarke admin w `admin/templates/site/main-layout.php` (produkty + zamowienia). +- Dodano endpoint AJAX `SettingsController::globalSearchAjax()` w `autoload/admin/Controllers/SettingsController.php`. +- Usunieto fasade `autoload/admin/factory/class.Integrations.php`. +- Wywołania integracji przepiete bezposrednio na `Domain/Integrations/IntegrationsRepository.php`. + ### Routing admin (admin\Site::route()) 1. Sprawdź mapę `$newControllers` → utwórz instancję z DI → wywołaj 2. Jeśli nowy kontroler nie istnieje (`class_exists()` = false) → fallback na `admin\controls\` diff --git a/docs/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md index 257c0f6..ccaadfa 100644 --- a/docs/REFACTORING_PLAN.md +++ b/docs/REFACTORING_PLAN.md @@ -156,6 +156,7 @@ grep -r "Product::getQuantity" . | 25 | ShopProduct (mass_edit) | 0.274 | DI kontroler + routing dla `mass_edit`, `mass_edit_save`, `get_products_by_category`, cleanup legacy akcji | | 26 | ShopClients | 0.274 | DI kontroler + routing dla `list/details`, nowe listy na `components/table-list`, cleanup legacy controls/factory | | 27 | ShopCategory | 0.275 | CategoryRepository + DI kontroler + routing, endpointy AJAX (`save_categories_order`, `save_products_order`, `cookie_categories`), cleanup legacy controls/factory/view | +| 28 | ShopOrder | 0.276 | OrderRepository + OrderAdminService + DI kontroler + routing + nowe widoki (`orders-list`, `order-details`, `order-edit`) + cleanup legacy controls/factory/view-list | ### Product - szczegolowy status - ✅ getQuantity (ver. 0.238) @@ -170,15 +171,14 @@ grep -r "Product::getQuantity" . - [ ] getProductImg ### 📋 Do zrobienia -- Order - ShopProduct (factory) ## Kolejność refaktoryzacji (priorytet) -1-27: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport, ShopAttribute, ShopProductSets, ShopProducer, ShopProduct (mass_edit), ShopClients, ShopCategory +1-28: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport, ShopAttribute, ShopProductSets, ShopProducer, ShopProduct (mass_edit), ShopClients, ShopCategory, ShopOrder Nastepne: -28. **Order** +29. **ShopProduct (factory)** ## Form Edit System diff --git a/docs/TESTING.md b/docs/TESTING.md index 2e6d2cc..af9b62a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -36,7 +36,13 @@ Alternatywnie (Git Bash): Ostatnio zweryfikowano: 2026-02-15 ```text -OK (377 tests, 1197 assertions) +OK (385 tests, 1246 assertions) +``` + +Aktualizacja po stabilizacji ShopOrder / Integrations / Global Search (2026-02-15, ver. 0.277): +```text +Pelny suite: OK (385 tests, 1246 assertions) +SettingsControllerTest: OK (7 tests, 10 assertions) ``` Aktualizacja po migracji ShopClients (2026-02-15, ver. 0.274) - testy punktowe: @@ -54,11 +60,18 @@ Pelny suite po migracji ShopCategory (2026-02-15, ver. 0.275): OK (377 tests, 1197 assertions) ``` +Aktualizacja po migracji ShopOrder (2026-02-15, ver. 0.276) - testy punktowe: +```text +OK (8 tests, 49 assertions) +``` + Nowe testy dodane 2026-02-15: - `tests/Unit/Domain/Client/ClientRepositoryTest.php` - `tests/Unit/admin/Controllers/ShopClientsControllerTest.php` - `tests/Unit/Domain/Category/CategoryRepositoryTest.php` - `tests/Unit/admin/Controllers/ShopCategoryControllerTest.php` +- `tests/Unit/Domain/Order/OrderRepositoryTest.php` +- `tests/Unit/admin/Controllers/ShopOrderControllerTest.php` ## Struktura testow diff --git a/tests/Unit/Domain/Order/OrderRepositoryTest.php b/tests/Unit/Domain/Order/OrderRepositoryTest.php new file mode 100644 index 0000000..31fc212 --- /dev/null +++ b/tests/Unit/Domain/Order/OrderRepositoryTest.php @@ -0,0 +1,94 @@ +createMock(\medoo::class); + $mockDb->method('select') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_statuses') { + return [ + ['id' => 0, 'status' => 'Nowe'], + ['id' => 4, 'status' => 'W realizacji'], + ]; + } + + return []; + }); + + $repository = new OrderRepository($mockDb); + $statuses = $repository->orderStatuses(); + + $this->assertIsArray($statuses); + $this->assertSame('Nowe', $statuses[0]); + $this->assertSame('W realizacji', $statuses[4]); + } + + public function testNextAndPrevOrderIdReturnNullForInvalidInput(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->never())->method('get'); + + $repository = new OrderRepository($mockDb); + + $this->assertNull($repository->nextOrderId(0)); + $this->assertNull($repository->prevOrderId(0)); + } + + public function testListForAdminReturnsItemsAndTotal(): void + { + $mockDb = $this->createMock(\medoo::class); + + $resultSetCount = new class { + public function fetchAll(): array + { + return [[3]]; + } + }; + + $resultSetData = new class { + public function fetchAll(): array + { + return [ + [ + 'id' => 11, + 'number' => '2026/02/001', + 'date_order' => '2026-02-15 10:00:00', + 'client' => 'Jan Kowalski', + 'order_email' => 'jan@example.com', + 'address' => 'Testowa 1, 00-000 Warszawa', + 'status' => 0, + 'client_phone' => '111222333', + 'transport' => 'Kurier', + 'payment_method' => 'Przelew', + 'summary' => '123.45', + 'paid' => 1, + 'total_orders' => 2, + ], + ]; + } + }; + + $callIndex = 0; + $mockDb->method('query') + ->willReturnCallback(function () use (&$callIndex, $resultSetCount, $resultSetData) { + $callIndex++; + return $callIndex === 1 ? $resultSetCount : $resultSetData; + }); + + $repository = new OrderRepository($mockDb); + $result = $repository->listForAdmin([], 'date_order', 'DESC', 1, 15); + + $this->assertSame(3, $result['total']); + $this->assertCount(1, $result['items']); + $this->assertSame(11, $result['items'][0]['id']); + $this->assertSame('2026/02/001', $result['items'][0]['number']); + $this->assertSame(2, $result['items'][0]['total_orders']); + $this->assertSame(1, $result['items'][0]['paid']); + } +} diff --git a/tests/Unit/admin/Controllers/ShopOrderControllerTest.php b/tests/Unit/admin/Controllers/ShopOrderControllerTest.php new file mode 100644 index 0000000..f055c68 --- /dev/null +++ b/tests/Unit/admin/Controllers/ShopOrderControllerTest.php @@ -0,0 +1,84 @@ +service = $this->createMock(OrderAdminService::class); + $this->controller = new ShopOrderController($this->service); + } + + public function testConstructorAcceptsService(): void + { + $controller = new ShopOrderController($this->service); + $this->assertInstanceOf(ShopOrderController::class, $controller); + } + + public function testHasExpectedActionMethods(): void + { + $this->assertTrue(method_exists($this->controller, 'list')); + $this->assertTrue(method_exists($this->controller, 'view_list')); + $this->assertTrue(method_exists($this->controller, 'details')); + $this->assertTrue(method_exists($this->controller, 'order_details')); + $this->assertTrue(method_exists($this->controller, 'edit')); + $this->assertTrue(method_exists($this->controller, 'order_edit')); + $this->assertTrue(method_exists($this->controller, 'save')); + $this->assertTrue(method_exists($this->controller, 'order_save')); + $this->assertTrue(method_exists($this->controller, 'notes_save')); + $this->assertTrue(method_exists($this->controller, 'order_status_change')); + $this->assertTrue(method_exists($this->controller, 'order_resend_confirmation_email')); + $this->assertTrue(method_exists($this->controller, 'set_order_as_unpaid')); + $this->assertTrue(method_exists($this->controller, 'set_order_as_paid')); + $this->assertTrue(method_exists($this->controller, 'send_order_to_apilo')); + $this->assertTrue(method_exists($this->controller, 'toggle_trustmate_send')); + $this->assertTrue(method_exists($this->controller, 'delete')); + $this->assertTrue(method_exists($this->controller, 'order_delete')); + } + + public function testViewActionsReturnString(): void + { + $reflection = new \ReflectionClass($this->controller); + + $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('view_list')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('details')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('order_details')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('order_edit')->getReturnType()); + } + + public function testMutationActionsReturnVoid(): void + { + $reflection = new \ReflectionClass($this->controller); + + $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('order_save')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('notes_save')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('order_status_change')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('order_resend_confirmation_email')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('set_order_as_unpaid')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('set_order_as_paid')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('send_order_to_apilo')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('toggle_trustmate_send')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('order_delete')->getReturnType()); + } + + public function testConstructorRequiresOrderAdminService(): void + { + $reflection = new \ReflectionClass(ShopOrderController::class); + $constructor = $reflection->getConstructor(); + $params = $constructor->getParameters(); + + $this->assertCount(1, $params); + $this->assertEquals('Domain\\Order\\OrderAdminService', $params[0]->getType()->getName()); + } +} diff --git a/updates/0.20/ver_0.276.zip b/updates/0.20/ver_0.276.zip new file mode 100644 index 0000000000000000000000000000000000000000..850cab26aaac4210d3ea410d3a93086dffec4478 GIT binary patch literal 54975 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 literal 0 HcmV?d00001 diff --git a/updates/0.20/ver_0.276_files.txt b/updates/0.20/ver_0.276_files.txt new file mode 100644 index 0000000..93f4965 --- /dev/null +++ b/updates/0.20/ver_0.276_files.txt @@ -0,0 +1,4 @@ +F: ../admin/templates/shop-order/view-list.php +F: ../autoload/admin/controls/class.ShopOrder.php +F: ../autoload/admin/factory/class.Integrations.php +F: ../autoload/admin/factory/class.ShopOrder.php diff --git a/updates/changelog.php b/updates/changelog.php index a890c09..8919b54 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,3 +1,14 @@ +ver. 0.276 - 15.02.2026
+- NEW - migracja modulu `ShopOrder` do architektury Domain + DI (`Domain\Order\OrderRepository`, `Domain\Order\OrderAdminService`, `admin\Controllers\ShopOrderController`) +- UPDATE - modul `/admin/shop_order/*` przepiety na nowy routing (kanoniczny URL `/admin/shop_order/list/`) i nowe widoki (`orders-list`, `order-details`, `order-edit`) +- FIX - stabilizacja listy zamowien (`OrderRepository::listForAdmin`) oraz poprawa wygladu tabeli (`components/table-list`, wyrownanie komorek i `text-right`) +- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopOrder.php`, `autoload/admin/factory/class.ShopOrder.php`, `admin/templates/shop-order/view-list.php` +- UPDATE - usunieta fasada `autoload/admin/factory/class.Integrations.php`; wywolania przepiete na `Domain\Integrations\IntegrationsRepository` +- NEW - globalna wyszukiwarka admin (produkty + zamowienia) przy "Wyczysc cache" + endpoint `/admin/settings/globalSearchAjax/` +- FIX - wyszukiwanie po pelnym imieniu i nazwisku w global search +- UPDATE - testy: `OK (385 tests, 1246 assertions)` +- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.276.zip`, `ver_0.276_files.txt` +
ver. 0.275 - 15.02.2026
- NEW - migracja modulu `ShopCategory` do architektury Domain + DI (`Domain\Category\CategoryRepository`, `admin\Controllers\ShopCategoryController`) - UPDATE - modul `/admin/shop_category/*` przepiety na nowy routing (kanoniczny URL `/admin/shop_category/list/`) i endpointy AJAX kontrolera (`save_categories_order`, `save_products_order`, `cookie_categories`) diff --git a/updates/versions.php b/updates/versions.php index bf31f67..176ae77 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@