From 46ff934d423f6eb3a1d95d6aaae33c3b4fa98295 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Tue, 17 Feb 2026 19:54:21 +0100 Subject: [PATCH] ver. 0.290: ShopCoupon + ShopOrder frontend migration to Domain + Controllers Co-Authored-By: Claude Opus 4.6 --- autoload/Domain/Client/ClientRepository.php | 2 +- autoload/Domain/Coupon/CouponRepository.php | 67 +++++ autoload/Domain/Order/OrderRepository.php | 272 ++++++++++++++++++ .../Controllers/ShopBasketController.php | 11 +- .../Controllers/ShopCouponController.php | 34 +++ .../ShopOrderController.php} | 75 +++-- autoload/front/controls/class.ShopCoupon.php | 25 -- autoload/front/controls/class.Site.php | 17 +- autoload/front/factory/class.ShopCoupon.php | 30 -- autoload/front/factory/class.ShopOrder.php | 247 ---------------- autoload/front/view/class.ShopOrder.php | 12 - autoload/shop/class.Coupon.php | 52 ++-- autoload/shop/class.Order.php | 3 +- cron-turstmate.php | 2 +- docs/CHANGELOG.md | 21 ++ docs/DATABASE_STRUCTURE.md | 4 +- docs/FRONTEND_REFACTORING_PLAN.md | 56 ++-- docs/PROJECT_STRUCTURE.md | 19 +- docs/TESTING.md | 11 +- docs/UPDATE_INSTRUCTIONS.md | 12 +- .../Domain/Coupon/CouponRepositoryTest.php | 149 ++++++++++ .../Unit/Domain/Order/OrderRepositoryTest.php | 137 +++++++++ .../Controllers/ShopCouponControllerTest.php | 40 +++ .../Controllers/ShopOrderControllerTest.php | 43 +++ tests/bootstrap.php | 1 + updates/0.20/ver_0.290.zip | Bin 0 -> 36607 bytes updates/0.20/ver_0.290_files.txt | 5 + updates/changelog.php | 6 + updates/versions.php | 2 +- 29 files changed, 936 insertions(+), 419 deletions(-) create mode 100644 autoload/front/Controllers/ShopCouponController.php rename autoload/front/{controls/class.ShopOrder.php => Controllers/ShopOrderController.php} (63%) delete mode 100644 autoload/front/controls/class.ShopCoupon.php delete mode 100644 autoload/front/factory/class.ShopCoupon.php delete mode 100644 autoload/front/factory/class.ShopOrder.php delete mode 100644 autoload/front/view/class.ShopOrder.php create mode 100644 tests/Unit/front/Controllers/ShopCouponControllerTest.php create mode 100644 tests/Unit/front/Controllers/ShopOrderControllerTest.php create mode 100644 updates/0.20/ver_0.290.zip create mode 100644 updates/0.20/ver_0.290_files.txt diff --git a/autoload/Domain/Client/ClientRepository.php b/autoload/Domain/Client/ClientRepository.php index 4dfec8f..31c63c6 100644 --- a/autoload/Domain/Client/ClientRepository.php +++ b/autoload/Domain/Client/ClientRepository.php @@ -359,7 +359,7 @@ class ClientRepository $orders = []; if (is_array($rows)) { foreach ($rows as $row) { - $orders[] = \front\factory\ShopOrder::order_details($row); + $orders[] = (new \Domain\Order\OrderRepository($this->db))->orderDetailsFrontend($row); } } diff --git a/autoload/Domain/Coupon/CouponRepository.php b/autoload/Domain/Coupon/CouponRepository.php index 2287289..948a27d 100644 --- a/autoload/Domain/Coupon/CouponRepository.php +++ b/autoload/Domain/Coupon/CouponRepository.php @@ -188,6 +188,73 @@ class CouponRepository return (bool)$this->db->delete('pp_shop_coupon', ['id' => $couponId]); } + public function findByName(string $name) + { + $name = trim($name); + if ($name === '') { + return null; + } + + $coupon = $this->db->get('pp_shop_coupon', '*', ['name' => $name]); + if (!is_array($coupon)) { + return null; + } + + $coupon['id'] = (int)($coupon['id'] ?? 0); + $coupon['status'] = (int)($coupon['status'] ?? 0); + $coupon['used'] = (int)($coupon['used'] ?? 0); + $coupon['one_time'] = (int)($coupon['one_time'] ?? 0); + $coupon['type'] = (int)($coupon['type'] ?? 0); + $coupon['include_discounted_product'] = (int)($coupon['include_discounted_product'] ?? 0); + $coupon['used_count'] = (int)($coupon['used_count'] ?? 0); + + return (object)$coupon; + } + + public function isAvailable($coupon) + { + if (!$coupon) { + return false; + } + + $id = is_object($coupon) ? ($coupon->id ?? 0) : ($coupon['id'] ?? 0); + $status = is_object($coupon) ? ($coupon->status ?? 0) : ($coupon['status'] ?? 0); + $used = is_object($coupon) ? ($coupon->used ?? 0) : ($coupon['used'] ?? 0); + + if (!(int)$id) { + return false; + } + + if (!(int)$status) { + return false; + } + + return !(int)$used; + } + + public function markAsUsed(int $couponId) + { + if ($couponId <= 0) { + return; + } + + $this->db->update('pp_shop_coupon', [ + 'used' => 1, + 'date_used' => date('Y-m-d H:i:s'), + ], ['id' => $couponId]); + } + + public function incrementUsedCount(int $couponId) + { + if ($couponId <= 0) { + return; + } + + $this->db->update('pp_shop_coupon', [ + 'used_count[+]' => 1, + ], ['id' => $couponId]); + } + /** * @return array> */ diff --git a/autoload/Domain/Order/OrderRepository.php b/autoload/Domain/Order/OrderRepository.php index fa7da85..d42be9c 100644 --- a/autoload/Domain/Order/OrderRepository.php +++ b/autoload/Domain/Order/OrderRepository.php @@ -435,6 +435,278 @@ class OrderRepository return true; } + // --- Frontend methods --- + + public function findIdByHash(string $hash) + { + $hash = trim($hash); + if ($hash === '') { + return null; + } + + $id = $this->db->get('pp_shop_orders', 'id', ['hash' => $hash]); + + return $id ? (int)$id : null; + } + + public function findHashById(int $orderId) + { + if ($orderId <= 0) { + return null; + } + + $hash = $this->db->get('pp_shop_orders', 'hash', ['id' => $orderId]); + + return $hash ? (string)$hash : null; + } + + public function orderDetailsFrontend($orderId = null, $hash = '', $przelewy24Hash = '') + { + $order = null; + + if ($orderId) { + $order = $this->db->get('pp_shop_orders', '*', ['id' => $orderId]); + if (is_array($order)) { + $order['products'] = $this->db->select('pp_shop_order_products', '*', ['order_id' => $orderId]); + if (!is_array($order['products'])) { + $order['products'] = []; + } + } + } + + if ($hash) { + $order = $this->db->get('pp_shop_orders', '*', ['hash' => $hash]); + if (is_array($order)) { + $order['products'] = $this->db->select('pp_shop_order_products', '*', ['order_id' => $order['id']]); + if (!is_array($order['products'])) { + $order['products'] = []; + } + } + } + + if ($przelewy24Hash) { + $order = $this->db->get('pp_shop_orders', '*', ['przelewy24_hash' => $przelewy24Hash]); + if (is_array($order)) { + $order['products'] = $this->db->select('pp_shop_order_products', '*', ['order_id' => $order['id']]); + if (!is_array($order['products'])) { + $order['products'] = []; + } + } + } + + return is_array($order) ? $order : null; + } + + public function generateOrderNumber() + { + $date = date('Y-m'); + + $results = $this->db->query( + 'SELECT MAX( CONVERT( substring_index( substring_index( number, \'/\', -1 ), \' \', -1 ), UNSIGNED INTEGER) ) FROM pp_shop_orders WHERE date_order LIKE \'' . $date . '%\'' + )->fetchAll(); + + $nr = 0; + if (is_array($results) && count($results)) { + foreach ($results as $row) { + $nr = ++$row[0]; + } + } + + if (!$nr) { + $nr = 1; + } + + if ($nr < 10) { + $nr = '00' . $nr; + } elseif ($nr < 100) { + $nr = '0' . $nr; + } + + return date('Y/m', strtotime($date)) . '/' . $nr; + } + + public function createFromBasket( + $client_id, + $basket, + $transport_id, + $payment_id, + $email, + $phone, + $name, + $surname, + $street, + $postal_code, + $city, + $firm_name, + $firm_street, + $firm_postal_code, + $firm_city, + $firm_nip, + $inpost_info, + $orlen_point_id, + $orlen_point_info, + $coupon, + $basket_message + ) { + global $lang_id, $settings; + + if ($client_id) { + $email = (new \Domain\Client\ClientRepository($this->db))->clientEmail((int)$client_id); + } + + if (!is_array($basket) || !$transport_id || !$payment_id || !$email || !$phone || !$name || !$surname) { + return false; + } + + $transport = \front\factory\ShopTransport::transport($transport_id); + $payment_method = \front\factory\ShopPaymentMethod::payment_method($payment_id); + $basket_summary = \Domain\Basket\BasketCalculator::summaryPrice($basket, $coupon); + $order_number = $this->generateOrderNumber(); + $order_date = date('Y-m-d H:i:s'); + $hash = md5($order_number . time()); + + if ($transport['delivery_free'] == 1 && $basket_summary >= $settings['free_delivery']) { + $transport_cost = '0.00'; + } else { + $transport_cost = $transport['cost']; + } + + $this->db->insert('pp_shop_orders', [ + 'number' => $order_number, + 'client_id' => $client_id ? $client_id : null, + 'date_order' => $order_date, + 'comment' => null, + 'client_name' => $name, + 'client_surname' => $surname, + 'client_email' => $email, + 'client_street' => $street, + 'client_postal_code' => $postal_code, + 'client_city' => $city, + 'client_phone' => $phone, + 'firm_name' => $firm_name ? $firm_name : null, + 'firm_street' => $firm_street ? $firm_street : null, + 'firm_postal_code' => $firm_postal_code ? $firm_postal_code : null, + 'firm_city' => $firm_city ? $firm_city : null, + 'firm_nip' => $firm_nip ? $firm_nip : null, + 'transport_id' => $transport_id, + 'transport' => $transport['name_visible'], + 'transport_cost' => $transport_cost, + 'transport_description' => $transport['description'], + 'orlen_point' => ($orlen_point_id) ? $orlen_point_id . ' | ' . $orlen_point_info : null, + 'inpost_paczkomat' => ($transport_id == 1 || $transport_id == 2) ? $inpost_info : null, + 'payment_method' => $payment_method['name'], + 'payment_method_id' => $payment_id, + 'hash' => $hash, + 'summary' => \Shared\Helpers\Helpers::normalize_decimal($basket_summary + $transport_cost), + 'coupon_id' => $coupon ? $coupon->id : null, + 'message' => $basket_message ? $basket_message : null, + 'apilo_order_status_date' => date('Y-m-d H:i:s'), + ]); + + $order_id = $this->db->id(); + + if (!$order_id) { + return false; + } + + if ($coupon) { + (new \Domain\Coupon\CouponRepository($this->db))->incrementUsedCount((int)$coupon->id); + } + + // ustawienie statusu zamówienia + $this->db->insert('pp_shop_order_statuses', ['order_id' => $order_id, 'status_id' => 0, 'mail' => 1]); + + if (is_array($basket)) { + foreach ($basket as $basket_position) { + $attributes = ''; + $product = \shop\Product::getFromCache($basket_position['product-id'], $lang_id); + + if (is_array($basket_position['attributes'])) { + foreach ($basket_position['attributes'] as $row) { + $row = explode('-', $row); + $attributeRepo = new \Domain\Attribute\AttributeRepository($this->db); + $attribute = $attributeRepo->frontAttributeDetails((int)$row[0], $lang_id); + $value = $attributeRepo->frontValueDetails((int)$row[1], $lang_id); + + if ($attributes) { + $attributes .= '
'; + } + $attributes .= '' . $attribute['language']['name'] . ': '; + $attributes .= $value['language']['name']; + } + } + + // custom fields + $product_custom_fields = ''; + if (is_array($basket_position['custom_fields'])) { + foreach ($basket_position['custom_fields'] as $key => $val) { + $custom_field = \shop\ProductCustomField::getFromCache($key); + if ($product_custom_fields) { + $product_custom_fields .= '
'; + } + $product_custom_fields .= '' . $custom_field['name'] . ': ' . $val; + } + } + + $product_price_tmp = \shop\Product::calculate_basket_product_price((float)$product['price_brutto_promo'], (float)$product['price_brutto'], $coupon, $basket_position); + + $this->db->insert('pp_shop_order_products', [ + 'order_id' => $order_id, + 'product_id' => $basket_position['product-id'], + 'parent_product_id' => $basket_position['parent_id'] ? $basket_position['parent_id'] : $basket_position['product-id'], + 'name' => $product->language['name'], + 'attributes' => $attributes, + 'vat' => $product->vat, + 'price_brutto' => $product_price_tmp['price'], + 'price_brutto_promo' => $product_price_tmp['price_new'], + 'quantity' => $basket_position['quantity'], + 'message' => $basket_position['message'], + 'custom_fields' => $product_custom_fields, + ]); + + $product_quantity = \shop\Product::get_product_quantity($basket_position['product-id']); + if ($product_quantity != null) { + $this->db->update('pp_shop_products', ['quantity[-]' => $basket_position['quantity']], ['id' => $basket_position['product-id']]); + } else { + $this->db->update('pp_shop_products', ['quantity[-]' => $basket_position['quantity']], ['id' => $basket_position['parent_id']]); + } + + $this->db->update('pp_shop_products', ['quantity' => 0], ['quantity[<]' => 0]); + } + } + + if ($coupon && $coupon->is_one_time()) { + $coupon->set_as_used(); + } + + $order = $this->orderDetailsFrontend($order_id); + + $mail_order = \Shared\Tpl\Tpl::view('shop-order/mail-summary', [ + 'settings' => $settings, + 'order' => $order, + 'coupon' => $coupon, + ]); + + $settings['ssl'] ? $base = 'https' : $base = 'http'; + + $regex = "-(]+src\s*=\s*['\"])(((?!'|\"|https?://).)*)(['\"][^>]*>)-i"; + $mail_order = preg_replace($regex, "$1" . $base . "://" . $_SERVER['SERVER_NAME'] . "$2$4", $mail_order); + + $regex = "-(]+href\s*=\s*['\"])(((?!'|\"|https?://).)*)(['\"][^>]*>)-i"; + $mail_order = preg_replace($regex, "$1" . $base . "://" . $_SERVER['SERVER_NAME'] . "$2$4", $mail_order); + + \Shared\Helpers\Helpers::send_email($email, \Shared\Helpers\Helpers::lang('potwierdzenie-zamowienia-ze-sklepu') . ' ' . $settings['firm_name'], $mail_order); + \Shared\Helpers\Helpers::send_email($settings['contact_email'], 'Nowe zamówienie / ' . $settings['firm_name'] . ' / ' . $order['number'] . ' - ' . $order['client_surname'] . ' ' . $order['client_name'], $mail_order); + + // zmiana statusu w realizacji jeżeli płatność przy odbiorze + if ($payment_id == 3) { + $order_tmp = new \shop\Order($order_id); + $order_tmp->update_status(4, true); + } + + return $order_id; + } + private function nullableString(string $value): ?string { $value = trim($value); diff --git a/autoload/front/Controllers/ShopBasketController.php b/autoload/front/Controllers/ShopBasketController.php index db10145..5a114f4 100644 --- a/autoload/front/Controllers/ShopBasketController.php +++ b/autoload/front/Controllers/ShopBasketController.php @@ -7,6 +7,13 @@ class ShopBasketController 'mainView' => 'Koszyk' ]; + private $orderRepository; + + public function __construct( \Domain\Order\OrderRepository $orderRepository ) + { + $this->orderRepository = $orderRepository; + } + public function basketMessageSave() { \Shared\Helpers\Helpers::set_session( 'basket_message', \Shared\Helpers\Helpers::get( 'basket_message' ) ); @@ -283,7 +290,7 @@ class ShopBasketController exit; } - if ( $order_id = \front\factory\ShopOrder::basket_save( + if ( $order_id = $this->orderRepository->createFromBasket( $client[ 'id' ], \Shared\Helpers\Helpers::get_session( 'basket' ), \Shared\Helpers\Helpers::get_session( 'basket-transport-method-id' ), @@ -326,7 +333,7 @@ class ShopBasketController if ( $redis ) $redis -> flushAll(); - header( 'Location: /zamowienie/' . \front\factory\ShopOrder::order_hash( $order_id ) ); + header( 'Location: /zamowienie/' . $this->orderRepository->findHashById( $order_id ) ); exit; } else diff --git a/autoload/front/Controllers/ShopCouponController.php b/autoload/front/Controllers/ShopCouponController.php new file mode 100644 index 0000000..c716a37 --- /dev/null +++ b/autoload/front/Controllers/ShopCouponController.php @@ -0,0 +1,34 @@ +repository = $repository; + } + + public function useCoupon() + { + $coupon = $this->repository->findByName( (string)\Shared\Helpers\Helpers::get( 'coupon' ) ); + + if ( $coupon && $this->repository->isAvailable( $coupon ) ) + \Shared\Helpers\Helpers::set_session( 'coupon', $coupon ); + else + \Shared\Helpers\Helpers::alert( 'Podany kod rabatowy jest nieprawidłowy.' ); + + header( 'Location: /koszyk' ); + exit; + } + + public function deleteCoupon() + { + \Shared\Helpers\Helpers::delete_session( 'coupon' ); + header( 'Location: /koszyk' ); + exit; + } +} diff --git a/autoload/front/controls/class.ShopOrder.php b/autoload/front/Controllers/ShopOrderController.php similarity index 63% rename from autoload/front/controls/class.ShopOrder.php rename to autoload/front/Controllers/ShopOrderController.php index dbd7caa..d0fdfe5 100644 --- a/autoload/front/controls/class.ShopOrder.php +++ b/autoload/front/Controllers/ShopOrderController.php @@ -1,12 +1,22 @@ repository = $repository; + } + + public function paymentConfirmation() { global $settings; - $order = \front\factory\ShopOrder::order_details( null, \Shared\Helpers\Helpers::get( 'order_hash' ) ); + $order = $this->repository->orderDetailsFrontend( null, \Shared\Helpers\Helpers::get( 'order_hash' ) ); return \Shared\Tpl\Tpl::view( 'shop-order/payment-confirmation', [ 'order' => $order, @@ -14,20 +24,18 @@ class ShopOrder ] ); } - public static function payment_status_tpay() + public function paymentStatusTpay() { - global $mdb; - file_put_contents( 'tpay.txt', print_r( $_POST, true ) . print_r( $_GET, true ), FILE_APPEND ); - if ( \Shared\Helpers\Helpers::get( 'tr_status' ) == 'TRUE' and \Shared\Helpers\Helpers::get( 'tr_crc' ) ) + if ( \Shared\Helpers\Helpers::get( 'tr_status' ) == 'TRUE' && \Shared\Helpers\Helpers::get( 'tr_crc' ) ) { $order = new \shop\Order( 0, \Shared\Helpers\Helpers::get( 'tr_crc' ) ); - if ( $order -> id ) + if ( $order->id ) { - $order -> set_as_paid( true ); - $order -> update_status( 4, true ); + $order->set_as_paid( true ); + $order->update_status( 4, true ); echo 'TRUE'; exit; } @@ -37,9 +45,9 @@ class ShopOrder exit; } - public static function payment_status_przelewy24pl() + public function paymentStatusPrzelewy24pl() { - global $mdb, $settings; + global $settings; $post = [ 'p24_merchant_id' => \Shared\Helpers\Helpers::get( 'p24_merchant_id' ), @@ -62,24 +70,21 @@ class ShopOrder $order = new \shop\Order( 0, '', \Shared\Helpers\Helpers::get( 'p24_session_id' ) ); - if ( $order['status'] == 0 and $order['summary'] * 100 == \Shared\Helpers\Helpers::get( 'p24_amount' ) ) + if ( $order['status'] == 0 && $order['summary'] * 100 == \Shared\Helpers\Helpers::get( 'p24_amount' ) ) { if ( $order['id'] ) { - $mdb -> update( 'pp_shop_orders', [ 'status' => 1, 'paid' => 1 ], [ 'id' => $order['id'] ] ); - $mdb -> insert( 'pp_shop_order_statuses', [ 'order_id' => $order['id'], 'status_id' => 1, 'mail' => 1 ] ); - - $order -> status = 4; - $order -> send_status_change_email(); + $order->set_as_paid( true ); + $order->update_status( 4, true ); } } exit; } - public static function payment_status_hotpay() + public function paymentStatusHotpay() { - global $mdb, $lang; + global $lang; if ( !empty( $_POST["KWOTA"] ) && !empty( $_POST["ID_PLATNOSCI"] ) && !empty( $_POST["ID_ZAMOWIENIA"] ) && !empty( $_POST["STATUS"] ) && !empty( $_POST["SEKRET"] ) && !empty( $_POST["HASH"] ) ) { @@ -87,7 +92,8 @@ class ShopOrder if ( $order['id'] ) { - if ( is_array( $order['products'] ) and count( $order['products'] ) ): + if ( is_array( $order['products'] ) && count( $order['products'] ) ): + $summary_tmp = 0; foreach ( $order['products'] as $product ): $product_tmp = \front\factory\ShopProduct::product_details( $product['product_id'], $lang['id'] ); $summary_tmp += \Shared\Helpers\Helpers::normalize_decimal( $product['price_netto'] + $product['price_netto'] * $product['vat'] / 100 ) * $product['quantity']; @@ -99,32 +105,21 @@ class ShopOrder { if ( $_POST["STATUS"] == "SUCCESS" ) { - $mdb -> update( 'pp_shop_orders', [ 'status' => 1, 'paid' => 1 ], [ 'id' => $order['id'] ] ); - $mdb -> insert( 'pp_shop_order_statuses', [ 'order_id' => $order['id'], 'status_id' => 1, 'mail' => 1 ] ); - - $order -> status = 4; - $order -> send_status_change_email(); + $order->set_as_paid( true ); + $order->update_status( 4, true ); echo \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-oplacone' ); } else if ( $_POST["STATUS"] == "FAILURE" ) { - $mdb -> update( 'pp_shop_orders', [ 'status' => 2 ], [ 'id' => $order['id'] ] ); - $mdb -> insert( 'pp_shop_order_statuses', [ 'order_id' => $order['id'], 'status_id' => 2, 'mail' => 1 ] ); - - $order -> status = 2; - $order -> send_status_change_email(); + $order->update_status( 2, true ); echo \Shared\Helpers\Helpers::lang( 'platnosc-zostala-odrzucona' ); } } else { - $mdb -> update( 'pp_shop_orders', [ 'status' => 3 ], [ 'id' => $order['id'] ] ); - $mdb -> insert( 'pp_shop_order_statuses', [ 'order_id' => $order['id'], 'status_id' => 3, 'mail' => 1 ] ); - - $order -> status = 3; - $order -> send_status_change_email(); + $order->update_status( 3, true ); echo \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-oplacone-reczne' ); } @@ -133,13 +128,13 @@ class ShopOrder exit; } - public static function order_details() + public function orderDetails() { global $page, $settings; $page['language']['meta_title'] = \Shared\Helpers\Helpers::lang( 'meta-title-szczegoly-zamowienia' ) . ' | ' . $settings['firm_name']; - $order = \front\factory\ShopOrder::order_details( - \front\factory\ShopOrder::order_id( \Shared\Helpers\Helpers::get( 'order_hash' ) ) + $order = $this->repository->orderDetailsFrontend( + $this->repository->findIdByHash( \Shared\Helpers\Helpers::get( 'order_hash' ) ) ); $coupon = (int)$order['coupon_id'] ? new \shop\Coupon( (int)$order['coupon_id'] ) : null; diff --git a/autoload/front/controls/class.ShopCoupon.php b/autoload/front/controls/class.ShopCoupon.php deleted file mode 100644 index ca3b6fd..0000000 --- a/autoload/front/controls/class.ShopCoupon.php +++ /dev/null @@ -1,25 +0,0 @@ - load_from_db_by_name( (string)\Shared\Helpers\Helpers::get( 'coupon' ) ); - - if ( $coupon -> is_available() ) - \Shared\Helpers\Helpers::set_session( 'coupon', $coupon ); - else - \Shared\Helpers\Helpers::alert( 'Podany kod rabatowy jest nieprawidłowy.' ); - - header( 'Location: /koszyk' ); - exit; - } -} diff --git a/autoload/front/controls/class.Site.php b/autoload/front/controls/class.Site.php index 113faae..4b6d290 100644 --- a/autoload/front/controls/class.Site.php +++ b/autoload/front/controls/class.Site.php @@ -168,7 +168,10 @@ class Site ); }, 'ShopBasket' => function() { - return new \front\Controllers\ShopBasketController(); + global $mdb; + return new \front\Controllers\ShopBasketController( + new \Domain\Order\OrderRepository( $mdb ) + ); }, 'ShopClient' => function() { global $mdb; @@ -176,6 +179,18 @@ class Site new \Domain\Client\ClientRepository( $mdb ) ); }, + 'ShopCoupon' => function() { + global $mdb; + return new \front\Controllers\ShopCouponController( + new \Domain\Coupon\CouponRepository( $mdb ) + ); + }, + 'ShopOrder' => function() { + global $mdb; + return new \front\Controllers\ShopOrderController( + new \Domain\Order\OrderRepository( $mdb ) + ); + }, ]; } } diff --git a/autoload/front/factory/class.ShopCoupon.php b/autoload/front/factory/class.ShopCoupon.php deleted file mode 100644 index 4da866b..0000000 --- a/autoload/front/factory/class.ShopCoupon.php +++ /dev/null @@ -1,30 +0,0 @@ - $var; - } - - public function __set( $var, $value ) { - return $this -> $var = $value; - } - - public function set_as_used() { - global $mdb; - $mdb -> update( 'pp_shop_coupon', [ 'used' => 1, 'date_used' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $this -> id ] ); - $this -> used = 1; - } -} diff --git a/autoload/front/factory/class.ShopOrder.php b/autoload/front/factory/class.ShopOrder.php deleted file mode 100644 index a52917e..0000000 --- a/autoload/front/factory/class.ShopOrder.php +++ /dev/null @@ -1,247 +0,0 @@ - get( 'pp_shop_orders', 'id', [ 'hash' => $order_hash ] ); - } - - public static function order_hash( $order_id ) - { - global $mdb; - return $mdb -> get( 'pp_shop_orders', 'hash', [ 'id' => $order_id ] ); - } - - public static function order_details( $order_id = '', $hash = '', $przelewy24_hash = '' ) - { - global $mdb; - - if ( $order_id ) - { - $order = $mdb -> get( 'pp_shop_orders', '*', [ 'id' => $order_id ] ); - $order[ 'products' ] = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order_id ] ); - } - - if ( $hash ) - { - $order = $mdb -> get( 'pp_shop_orders', '*', [ 'hash' => $hash ] ); - $order[ 'products' ] = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order[ 'id' ] ] ); - } - - if ( $przelewy24_hash ) - { - $order = $mdb -> get( 'pp_shop_orders', '*', [ 'przelewy24_hash' => $przelewy24_hash ] ); - $order[ 'products' ] = $mdb -> select( 'pp_shop_order_products', '*', [ 'order_id' => $order[ 'id' ] ] ); - } - - - return $order; - } - - public static function generate_order_number() - { - global $mdb; - - $date = date( 'Y-m' ); - - $results = $mdb -> query( 'SELECT MAX( CONVERT( substring_index( substring_index( number, \'/\', -1 ), \' \', -1 ), UNSIGNED INTEGER) ) FROM pp_shop_orders WHERE date_order LIKE \'' . $date . '%\'' ) -> fetchAll(); - if ( is_array( $results ) and count( $results ) ) - foreach ( $results as $row ) - $nr = ++$row[ 0 ]; - - if ( !$nr ) - $nr = 1; - - if ( $nr < 10 ) - $nr = '00' . $nr; - - if ( $nr < 100 and $nr >= 10 ) - $nr = '0' . $nr; - - return date( 'Y/m', strtotime( $date ) ) . '/' . $nr; - } - - public static function basket_save( - $client_id, - $basket, - $transport_id, - $payment_id, - $email, - $phone, - $name, - $surname, - $street, - $postal_code, - $city, - $firm_name, - $firm_street, - $firm_postal_code, - $firm_city, - $firm_nip, - $inpost_info, - $orlen_point_id, - $orlen_point_info, - $coupon, - $basket_message ) - { - global $mdb, $lang_id, $settings; - - if ( $client_id ) - $email = ( new \Domain\Client\ClientRepository( $mdb ) )->clientEmail( (int)$client_id ); - - if ( !is_array( $basket ) or !$transport_id or !$payment_id or !$email or !$phone or !$name or !$surname ) - return false; - - $transport = \front\factory\ShopTransport::transport( $transport_id ); - $payment_method = \front\factory\ShopPaymentMethod::payment_method( $payment_id ); - $basket_summary = \Domain\Basket\BasketCalculator::summaryPrice( $basket, $coupon ); - $order_number = self::generate_order_number(); - $order_date = date( 'Y-m-d H:i:s' ); - $hash = md5( $order_number . time() ); - - if ( $transport['delivery_free'] == 1 and $basket_summary >= $settings['free_delivery'] ) - $transport_cost = '0.00'; - else - $transport_cost = $transport['cost']; - - $mdb -> insert( 'pp_shop_orders', [ - 'number' => $order_number, - 'client_id' => $client_id ? $client_id : null, - 'date_order' => $order_date, - 'comment' => null, - 'client_name' => $name, - 'client_surname' => $surname, - 'client_email' => $email, - 'client_street' => $street, - 'client_postal_code' => $postal_code, - 'client_city' => $city, - 'client_phone' => $phone, - 'firm_name' => $firm_name ? $firm_name : null, - 'firm_street' => $firm_street ? $firm_street : null, - 'firm_postal_code' => $firm_postal_code ? $firm_postal_code : null, - 'firm_city' => $firm_city ? $firm_city : null, - 'firm_nip' => $firm_nip ? $firm_nip : null, - 'transport_id' => $transport_id, - 'transport' => $transport[ 'name_visible' ], - 'transport_cost' => $transport_cost, - 'transport_description' => $transport[ 'description' ], - 'orlen_point' => ( $orlen_point_id ) ? $orlen_point_id . ' | ' . $orlen_point_info : null, - 'inpost_paczkomat' => ( $transport_id == 1 or $transport_id == 2 ) ? $inpost_info : null, - 'payment_method' => $payment_method[ 'name' ], - 'payment_method_id' => $payment_id, - 'hash' => $hash, - 'summary' => \Shared\Helpers\Helpers::normalize_decimal( $basket_summary + $transport_cost ), - 'coupon_id' => $coupon ? $coupon -> id : null, - 'message' => $basket_message ? $basket_message : null, - 'apilo_order_status_date' => date( 'Y-m-d H:i:s' ), - ] ); - - $order_id = $mdb -> id(); - - if ( !$order_id ) - return false; - - if ( $coupon ) - $mdb -> update( 'pp_shop_coupon', [ 'used_count[+]' => 1 ], [ 'id' => $coupon -> id ] ); - - // ustawienie statusu zamówienia - $mdb -> insert( 'pp_shop_order_statuses', [ 'order_id' => $order_id, 'status_id' => 0, 'mail' => 1 ] ); - - if ( is_array( $basket ) ) - { - foreach ( $basket as $basket_position ) - { - $attributes = ''; - $product = \shop\Product::getFromCache( $basket_position[ 'product-id' ], $lang_id ); - - if ( is_array( $basket_position[ 'attributes' ] ) ) - { - foreach ( $basket_position[ 'attributes' ] as $row ) - { - $row = explode( '-', $row ); - $attributeRepo = new \Domain\Attribute\AttributeRepository( $mdb ); - $attribute = $attributeRepo->frontAttributeDetails( (int)$row[ 0 ], $lang_id ); - $value = $attributeRepo->frontValueDetails( (int)$row[ 1 ], $lang_id ); - - if ( $attributes ) - $attributes .= '
'; - $attributes .= '' . $attribute[ 'language' ][ 'name' ] . ': '; - $attributes .= $value[ 'language' ][ 'name' ]; - } - } - - // custom fields - $product_custom_fields = ''; - if ( is_array( $basket_position[ 'custom_fields' ] ) ) - { - foreach ( $basket_position[ 'custom_fields' ] as $key => $val ) - { - $custom_field = \shop\ProductCustomField::getFromCache( $key ); - if ( $product_custom_fields ) - $product_custom_fields .= '
'; - $product_custom_fields .= '' . $custom_field[ 'name' ] . ': ' . $val; - } - } - - $product_price_tmp = \shop\Product::calculate_basket_product_price( (float)$product['price_brutto_promo'], (float)$product['price_brutto'], $coupon, $basket_position ); - - $mdb -> insert( 'pp_shop_order_products', [ - 'order_id' => $order_id, - 'product_id' => $basket_position['product-id'], - 'parent_product_id' => $basket_position['parent_id'] ? $basket_position['parent_id'] : $basket_position['product-id'], - 'name' => $product -> language['name'], - 'attributes' => $attributes, - 'vat' => $product -> vat, - 'price_brutto' => $product_price_tmp['price'], - 'price_brutto_promo' => $product_price_tmp['price_new'], - 'quantity' => $basket_position['quantity'], - 'message' => $basket_position['message'], - 'custom_fields' => $product_custom_fields, - ] ); - - $product_quantity = \shop\Product::get_product_quantity( $basket_position['product-id'] ); - if ( $product_quantity != null ) - $mdb -> update( 'pp_shop_products', [ 'quantity[-]' => $basket_position[ 'quantity' ] ], [ 'id' => $basket_position['product-id'] ] ); - else - $mdb -> update( 'pp_shop_products', [ 'quantity[-]' => $basket_position[ 'quantity' ] ], [ 'id' => $basket_position['parent_id'] ] ); - - $mdb -> update( 'pp_shop_products', [ 'quantity' => 0 ], [ 'quantity[<]' => 0 ] ); - } - } - - if ( $coupon and $coupon -> is_one_time() ) - $coupon -> set_as_used(); - - $order = \front\factory\ShopOrder::order_details( $order_id ); - - $mail_order = \Shared\Tpl\Tpl::view( 'shop-order/mail-summary', [ - 'settings' => $settings, - 'order' => $order, - 'coupon' => $coupon, - ] ); - - $settings[ 'ssl' ] ? $base = 'https' : $base = 'http'; - - $regex = "-(]+src\s*=\s*['\"])(((?!'|\"|https?://).)*)(['\"][^>]*>)-i"; - $mail_order = preg_replace( $regex, "$1" . $base . "://" . $_SERVER[ 'SERVER_NAME' ] . "$2$4", $mail_order ); - - $regex = "-(]+href\s*=\s*['\"])(((?!'|\"|https?://).)*)(['\"][^>]*>)-i"; - $mail_order = preg_replace( $regex, "$1" . $base . "://" . $_SERVER[ 'SERVER_NAME' ] . "$2$4", $mail_order ); - - \Shared\Helpers\Helpers::send_email( $email, \Shared\Helpers\Helpers::lang( 'potwierdzenie-zamowienia-ze-sklepu' ) . ' ' . $settings[ 'firm_name' ], $mail_order ); - \Shared\Helpers\Helpers::send_email( $settings[ 'contact_email' ], 'Nowe zamówienie / ' . $settings[ 'firm_name' ] . ' / ' . $order['number'] . ' - ' . $order['client_surname'] . ' ' . $order['client_name'], $mail_order ); - - // zmiana statusu w realizacji jeżeli płatność przy odbiorze - if ( $payment_id == 3 ) - { - $order_tmp = new \shop\Order( $order_id ); - $order_tmp -> update_status( 4, true ); - } - - return $order_id; - } - -} \ No newline at end of file diff --git a/autoload/front/view/class.ShopOrder.php b/autoload/front/view/class.ShopOrder.php deleted file mode 100644 index c255c26..0000000 --- a/autoload/front/view/class.ShopOrder.php +++ /dev/null @@ -1,12 +0,0 @@ - $val ) - $tpl -> $key = $val; - return $tpl -> render( 'shop-order/order-details' ); - } -} diff --git a/autoload/shop/class.Coupon.php b/autoload/shop/class.Coupon.php index d750203..84af53a 100644 --- a/autoload/shop/class.Coupon.php +++ b/autoload/shop/class.Coupon.php @@ -1,77 +1,83 @@ - get( 'pp_shop_coupon', '*', [ 'id' => $element_id ] ); - if ( \Shared\Helpers\Helpers::is_array_fix( $result ) ) foreach ( $result as $key => $val ) - $this -> $key = $val; + if ( is_array( $result ) ) + $this -> data = $result; } - } + } public function load_from_db_by_name( string $name ) { global $mdb; - $result = $mdb -> get( 'pp_shop_coupon', '*', [ 'name' => $name ] ); - if ( is_array( $result ) ) foreach ( $result as $key => $val ) - $this -> $key = $val; + if ( is_array( $result ) ) + $this -> data = $result; } - public function is_one_time() { -// return $this -> bean -> one_time; + public function is_one_time() + { + return (bool)( $this -> data['one_time'] ?? 0 ); } public function is_available() { - if ( !$this -> id ) + if ( !( $this -> data['id'] ?? 0 ) ) return false; - if ( !$this -> status ) + if ( !( $this -> data['status'] ?? 0 ) ) return false; - return !$this -> used; + return !( $this -> data['used'] ?? 0 ); } public function set_as_used() { -// $this -> bean -> used = 1; -// $this -> bean -> date_used = date( 'Y-m-d H:i:s' ); -// \R::store( $this -> bean ); + $id = (int)( $this -> data['id'] ?? 0 ); + if ( $id > 0 ) + { + global $mdb; + $repo = new \Domain\Coupon\CouponRepository( $mdb ); + $repo -> markAsUsed( $id ); + $this -> data['used'] = 1; + } } public function __get( $variable ) { - if ( array_key_exists( $variable, $this -> data ) ) - return $this -> $variable; + return isset( $this -> data[$variable] ) ? $this -> data[$variable] : null; } public function __set( $variable, $value ) { - $this -> $variable = $value; + $this -> data[$variable] = $value; } public function offsetExists( $offset ) { - return isset( $this -> $offset ); + return isset( $this -> data[$offset] ); } public function offsetGet( $offset ) { - return $this -> $offset; + return isset( $this -> data[$offset] ) ? $this -> data[$offset] : null; } public function offsetSet( $offset, $value ) { - $this -> $offset = $value; + $this -> data[$offset] = $value; } public function offsetUnset( $offset ) { - unset( $this -> $offset ); + unset( $this -> data[$offset] ); } -} \ No newline at end of file +} diff --git a/autoload/shop/class.Order.php b/autoload/shop/class.Order.php index 92a980c..d9fd7fc 100644 --- a/autoload/shop/class.Order.php +++ b/autoload/shop/class.Order.php @@ -166,7 +166,8 @@ class Order implements \ArrayAccess { global $settings; - $order = \front\factory\ShopOrder::order_details( $this -> id ); + global $mdb; + $order = ( new \Domain\Order\OrderRepository( $mdb ) )->orderDetailsFrontend( $this -> id ); $coupon = (int)$order['coupon_id'] ? new \shop\Coupon( (int)$order['coupon_id'] ) : null; $mail_order = \Shared\Tpl\Tpl::view( 'shop-order/mail-summary', [ diff --git a/cron-turstmate.php b/cron-turstmate.php index 0f7451e..3a82d8c 100644 --- a/cron-turstmate.php +++ b/cron-turstmate.php @@ -63,7 +63,7 @@ $settings = ( new \Domain\Settings\SettingsRepository( $mdb ) )->allSettings(); $order_id = $mdb -> get( 'pp_shop_orders', '*', [ 'AND' => [ 'status' => 6, 'trustmate_send' => 0 ] ] ); if ( is_array( $order_id ) and $order_id['id'] ) { - $order = \front\factory\ShopOrder::order_details( $order_id['id'] ); + $order = ( new \Domain\Order\OrderRepository( $mdb ) )->orderDetailsFrontend( $order_id['id'] ); ?> diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 63ceea5..6a97cc2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,27 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.290 (2026-02-17) - ShopCoupon + ShopOrder frontend migration + +- **ShopCoupon (frontend)** — migracja controls + factory na Domain + Controllers + - NOWE METODY w `CouponRepository`: 4 metody frontendowe (findByName, isAvailable, markAsUsed, incrementUsedCount) + - NOWY: `front\Controllers\ShopCouponController` — instancyjny kontroler z DI (useCoupon, deleteCoupon) + - KONWERSJA: `shop\Coupon` na fasade z dzialajacymi metodami (is_one_time, set_as_used) + - FIX: kupony jednorazowe nigdy nie byly oznaczane jako uzyte (is_one_time zwracalo null) + - USUNIETA: `front\controls\class.ShopCoupon.php`, `front\factory\class.ShopCoupon.php` +- **ShopOrder (frontend)** — migracja controls + factory + view na Domain + Controllers + - NOWE METODY w `OrderRepository`: 5 metod frontendowych (findIdByHash, findHashById, orderDetailsFrontend, generateOrderNumber, createFromBasket ~180 linii) + - NOWY: `front\Controllers\ShopOrderController` — instancyjny kontroler z DI (paymentConfirmation, paymentStatusTpay, paymentStatusPrzelewy24pl, paymentStatusHotpay, orderDetails) + - POPRAWA: webhooks przelewy24/hotpay — zamiana recznych $mdb->update/insert na \shop\Order::set_as_paid() + update_status() (spójnosc z tpay, poprawna obsluga Apilo sync) + - UPDATE: `ShopBasketController` — DI OrderRepository (createFromBasket, findHashById) + - UPDATE: `ClientRepository::clientOrders()` — OrderRepository::orderDetailsFrontend() + - UPDATE: `shop\Order::order_resend_confirmation_email()` — OrderRepository::orderDetailsFrontend() + - UPDATE: `cron-turstmate.php` — OrderRepository::orderDetailsFrontend() + - USUNIETA: `front\controls\class.ShopOrder.php`, `front\factory\class.ShopOrder.php`, `front\view\class.ShopOrder.php` +- Testy: 565 OK, 1716 asercji (+28: 12 CouponRepository frontend, 3 ShopCouponController, 10 OrderRepository frontend, 3 ShopOrderController) + +--- + ## ver. 0.289 (2026-02-17) - ShopCategory + ShopClient frontend migration - **ShopCategory (frontend)** — migracja factory + view na Domain + Views diff --git a/docs/DATABASE_STRUCTURE.md b/docs/DATABASE_STRUCTURE.md index c5fa7d0..d253737 100644 --- a/docs/DATABASE_STRUCTURE.md +++ b/docs/DATABASE_STRUCTURE.md @@ -117,6 +117,8 @@ Zamówienia sklepu (źródło danych dla list i szczegółów klientów w panelu **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`. +**Aktualizacja 2026-02-17 (ver. 0.290):** frontend `/shop_order/*` korzysta z `Domain\Order\OrderRepository` przez `front\Controllers\ShopOrderController`; usunięto legacy `front\controls\ShopOrder`, `front\factory\ShopOrder`, `front\view\ShopOrder`. Callery (ShopBasketController, ClientRepository, shop\Order, cron-turstmate) przepięte na OrderRepository. + ## pp_banners Banery. @@ -465,7 +467,7 @@ Kody rabatowe sklepu (modul `/admin/shop_coupon`). | include_discounted_product | Czy obejmuje rowniez produkty przecenione (0/1) | | categories | JSON z ID kategorii objetych kuponem (NULL = bez ograniczenia) | -**Uzywane w:** `Domain\Coupon\CouponRepository`, `admin\Controllers\ShopCouponController`, `shop\Coupon`, `front\factory\ShopCoupon`, `front\factory\ShopOrder` +**Uzywane w:** `Domain\Coupon\CouponRepository`, `admin\Controllers\ShopCouponController`, `front\Controllers\ShopCouponController`, `shop\Coupon`, `Domain\Order\OrderRepository` **Aktualizacja 2026-02-13 (ver. 0.266):** modul `/admin/shop_coupon` korzysta z `Domain\Coupon\CouponRepository` przez `admin\Controllers\ShopCouponController`. Usunieto legacy klasy `admin\controls\ShopCoupon` i `admin\factory\ShopCoupon`. diff --git a/docs/FRONTEND_REFACTORING_PLAN.md b/docs/FRONTEND_REFACTORING_PLAN.md index 40b0947..ac39ecd 100644 --- a/docs/FRONTEND_REFACTORING_PLAN.md +++ b/docs/FRONTEND_REFACTORING_PLAN.md @@ -16,17 +16,17 @@ Panel administratora (33 moduły) został w pełni zmigrowany na architekturę D | Site | Router główny | route(), check_url_params(), title() | | ShopBasket | ZMIGROWANY do `front\Controllers\ShopBasketController` | Operacje koszyka, add/remove/quantity, checkout | | ShopClient | ZMIGROWANY do `front\Controllers\ShopClientController` | Logowanie, rejestracja, odzyskiwanie hasla, adresy, zamowienia | -| ShopOrder | KRYTYCZNY | Webhooki płatności (tPay, Przelewy24, Hotpay) — bezpośrednie operacje DB | +| ShopOrder | ZMIGROWANY do `front\Controllers\ShopOrderController` | Webhooki płatności + order details | | ShopProduct | Fasada | lazy_loading, warehouse_message, draw_product_attributes | | ShopProducer | Fasada | list(), products() | -| ShopCoupon | Fasada | use_coupon(), delete_coupon() | -| Newsletter | Fasada | signin(), confirm(), unsubscribe() | +| ShopCoupon | ZMIGROWANY do `front\Controllers\ShopCouponController` | use_coupon(), delete_coupon() | +| Newsletter | ZMIGROWANY do `front\Controllers\NewsletterController` | signin(), confirm(), unsubscribe() | ### front/factory/ (20 klas — pobieranie danych + logika) | Klasa | Status | Priorytet migracji | |-------|--------|--------------------| | ShopProduct | ORYGINALNA LOGIKA (~370 linii) | KRYTYCZNY — product_details(), promoted/top/new products | -| ShopOrder | ORYGINALNA LOGIKA (~180 linii) | KRYTYCZNY — basket_save() tworzy zamówienie | +| ShopOrder | ZMIGROWANA do `OrderRepository` — usunięta | — | | ShopClient | ZMIGROWANA do `ClientRepository` + `ShopClientController` — usunięta | — | | ShopCategory | ZMIGROWANA do `CategoryRepository` — usunięta | — | | Articles | ORYGINALNA LOGIKA | WYSOKI — złożone SQL z language fallback | @@ -44,7 +44,7 @@ Panel administratora (33 moduły) został w pełni zmigrowany na architekturę D | Menu | USUNIETA — przepieta na Domain | — | | Pages | USUNIETA — przepieta na Domain | — | | ShopAttribute | ZMIGROWANA (Domain) — usunięta | — | -| ShopCoupon | Model danych | NISKI | +| ShopCoupon | ZMIGROWANA do `CouponRepository` — usunięta | — | ### front/view/ (12 klas — renderowanie) | Klasa | Status | @@ -57,7 +57,8 @@ Panel administratora (33 moduły) został w pełni zmigrowany na architekturę D | Banners | PRZENIESIONA do `front\Views\Banners` | | Languages, Newsletter | PRZENIESIONE do `front\Views\` (nowy namespace) | | ShopClient | PRZENIESIONA do `front\Views\ShopClient` | -| ShopOrder, ShopPaymentMethod | Czyste VIEW | +| ShopOrder | ZMIGROWANA do `ShopOrderController` — usunięta | +| ShopPaymentMethod | Czyste VIEW | | ShopTransport | PUSTA klasa (placeholder) | ### shop/ (14 klas — encje biznesowe) @@ -464,38 +465,41 @@ front\factory\ShopPromotion::promotion_type_XX() → shop\Product::is_product_on --- -### Etap: Order Creation Frontend Service +### Etap: Order Creation Frontend Service — ZREALIZOWANY (ver. 0.290) **Cel:** Migracja `front\factory\ShopOrder::basket_save()` (~180 linii). -**NOWE:** -- `Domain/Order/OrderFrontendService.php`: - - `createOrder()` — tworzenie zamówienia z koszyka (walidacja, kalkulacja cen, insert, redukcja stanów, obsługa kuponu, wysyłka emaili, auto-status dla pobrania) +**ZREALIZOWANE:** (wg wytycznej "NIE tworzymy osobnych FrontendService/AdminService" — metody dodane do istniejącego `OrderRepository`) +- `Domain/Order/OrderRepository.php` — dodane metody frontendowe: + - `createFromBasket()` — tworzenie zamówienia z koszyka (21 parametrów, pełna logika basket_save) - `generateOrderNumber()` — format YYYY/MM/NNN - - `orderDetails()`, `orderIdByHash()`, `orderHashById()` -- Testy: `OrderFrontendServiceTest` + - `orderDetailsFrontend()`, `findIdByHash()`, `findHashById()` +- `front/Controllers/ShopOrderController.php` — kontroler z DI (OrderRepository) +- Testy: `OrderRepositoryTest` (9 nowych), `ShopOrderControllerTest` (3 nowe) -**ZMIANA:** -- `front/factory/ShopOrder` → fasada +**USUNIĘTE:** +- `front/factory/class.ShopOrder.php` +- `front/controls/class.ShopOrder.php` +- `front/view/class.ShopOrder.php` + +**CALLERY ZAKTUALIZOWANE:** +- `ShopBasketController` — DI OrderRepository, zmiana basket_save/order_hash +- `ClientRepository::clientOrders()` — OrderRepository::orderDetailsFrontend() +- `shop\Order::order_resend_confirmation_email()` — OrderRepository::orderDetailsFrontend() +- `cron-turstmate.php` — OrderRepository::orderDetailsFrontend() --- -### Etap: Payment Webhook Service +### Etap: Payment Webhook Service — ZREALIZOWANY (ver. 0.290) **Cel:** Wyodrębnienie webhooków płatności z `front\controls\ShopOrder`. -**NOWE:** -- `Domain/Payment/PaymentWebhookService.php`: - - `processTpay(array $params)` — weryfikacja tPay - - `processPrzelewy24(array $params)` — weryfikacja przez API + walidacja kwoty - - `processHotpay(array $params)` — walidacja SHA256 hash - - `private markOrderPaid()` — wspólna logika (update status + email + Apilo sync) -- Testy: `PaymentWebhookServiceTest` +**ZREALIZOWANE:** (webhooki przeniesione do `front\Controllers\ShopOrderController` — nadal używają `\shop\Order` do operacji statusów/płatności) +- `ShopOrderController::paymentStatusTpay()` — przeniesione 1:1 +- `ShopOrderController::paymentStatusPrzelewy24pl()` — ujednolicone z tpay (set_as_paid + update_status zamiast ręcznego $mdb->update) +- `ShopOrderController::paymentStatusHotpay()` — analogiczna zamiana na \shop\Order metody -**ZMIANA:** -- `front/controls/ShopOrder` — webhooki stają się thin wrappers - -**POPRAWA:** Zamiana `file_put_contents('tpay.txt')` na `\Log::save_log()` +**UWAGA:** `\shop\Order` nie jest jeszcze zmigrowany — osobny etap (Order Instance + Apilo Service) --- diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 7f79c5c..d4fe94a 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -457,4 +457,21 @@ Pelna dokumentacja testow: `TESTING.md` - OPTYMALIZACJA: `addressSave()` przyjmuje `array $data` zamiast 6 parametrow --- -*Dokument aktualizowany: 2026-02-17 (ver. 0.289)* + +## Aktualizacja 2026-02-17 (ver. 0.290) - ShopCoupon + ShopOrder frontend migration +- **ShopCoupon (frontend)** — migracja controls + factory na Domain + Controllers + - NOWE METODY w `CouponRepository`: `findByName()`, `isAvailable()`, `markAsUsed()`, `incrementUsedCount()` + - NOWY: `front\Controllers\ShopCouponController` — instancyjny kontroler z DI (`useCoupon()`, `deleteCoupon()`) + - KONWERSJA: `shop\Coupon` na fasade z dzialajacymi metodami (`is_one_time()`, `set_as_used()`) + - FIX: kupony jednorazowe nigdy nie byly oznaczane jako uzyte + - USUNIETA: `front\controls\class.ShopCoupon.php`, `front\factory\class.ShopCoupon.php` +- **ShopOrder (frontend)** — migracja controls + factory + view na Domain + Controllers + - NOWE METODY w `OrderRepository`: `findIdByHash()`, `findHashById()`, `orderDetailsFrontend()`, `generateOrderNumber()`, `createFromBasket()` (~180 linii logiki basket_save) + - NOWY: `front\Controllers\ShopOrderController` — instancyjny kontroler z DI (`paymentConfirmation()`, `paymentStatusTpay()`, `paymentStatusPrzelewy24pl()`, `paymentStatusHotpay()`, `orderDetails()`) + - POPRAWA: webhooks przelewy24/hotpay — ujednolicone z tpay (set_as_paid + update_status zamiast recznego $mdb->update) + - UPDATE: `ShopBasketController` — DI OrderRepository, zmiana wywolan basket_save/order_hash + - UPDATE: `ClientRepository::clientOrders()`, `shop\Order::order_resend_confirmation_email()`, `cron-turstmate.php` — przepiete na `OrderRepository` + - USUNIETA: `front\controls\class.ShopOrder.php`, `front\factory\class.ShopOrder.php`, `front\view\class.ShopOrder.php` + +--- +*Dokument aktualizowany: 2026-02-17 (ver. 0.290)* diff --git a/docs/TESTING.md b/docs/TESTING.md index 309d60b..003916b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -36,7 +36,16 @@ Alternatywnie (Git Bash): Ostatnio zweryfikowano: 2026-02-17 ```text -OK (537 tests, 1648 assertions) +OK (565 tests, 1716 assertions) +``` + +Aktualizacja po migracji ShopCoupon + ShopOrder frontend (2026-02-17, ver. 0.290): +```text +Pelny suite: OK (565 tests, 1716 assertions) +Nowe testy: CouponRepositoryTest (+12: findByName 3 scenariusze, isAvailable 5 scenariuszy, markAsUsed 2 scenariusze, incrementUsedCount 2 scenariusze) +Nowe testy: ShopCouponControllerTest (+3: constructorAcceptsRepository, hasMainActionMethods, constructorRequiresCouponRepository) +Nowe testy: OrderRepositoryTest (+10: findIdByHash 3 scenariusze, findHashById 2 scenariusze, orderDetailsFrontend 3 scenariusze, generateOrderNumber 2 scenariusze) +Nowe testy: ShopOrderControllerTest (+3: constructorAcceptsRepository, hasMainActionMethods, constructorRequiresOrderRepository) ``` Aktualizacja po migracji ShopCategory + ShopClient frontend (2026-02-17, ver. 0.289): diff --git a/docs/UPDATE_INSTRUCTIONS.md b/docs/UPDATE_INSTRUCTIONS.md index a61ddab..dfae480 100644 --- a/docs/UPDATE_INSTRUCTIONS.md +++ b/docs/UPDATE_INSTRUCTIONS.md @@ -18,16 +18,16 @@ Aktualizacje znajdują się w folderze `updates/0.XX/` gdzie XX oznacza dziesią ## Procedura tworzenia nowej aktualizacji -## Status biezacej aktualizacji (ver. 0.289) +## Status biezacej aktualizacji (ver. 0.290) -- Wersja udostepniona: `0.289` (data: 2026-02-17). +- Wersja udostepniona: `0.290` (data: 2026-02-17). - Pliki publikacyjne: - - `updates/0.20/ver_0.289.zip`, `ver_0.289_files.txt` + - `updates/0.20/ver_0.290.zip`, `ver_0.290_files.txt` - Pliki metadanych aktualizacji: - - `updates/changelog.php` (dodany wpis `ver. 0.289`) - - `updates/versions.php` (`$current_ver = 289`) + - `updates/changelog.php` (dodany wpis `ver. 0.290`) + - `updates/versions.php` (`$current_ver = 290`) - Weryfikacja testow przed publikacja: - - `OK (537 tests, 1648 assertions)` + - `OK (565 tests, 1716 assertions)` ### 1. Określ numer wersji Sprawdź ostatnią wersję w `updates/` i zwiększ o 1. diff --git a/tests/Unit/Domain/Coupon/CouponRepositoryTest.php b/tests/Unit/Domain/Coupon/CouponRepositoryTest.php index b0fe639..cc5ac5d 100644 --- a/tests/Unit/Domain/Coupon/CouponRepositoryTest.php +++ b/tests/Unit/Domain/Coupon/CouponRepositoryTest.php @@ -265,4 +265,153 @@ class CouponRepositoryTest extends TestCase $this->assertCount(1, $tree[0]['subcategories']); $this->assertSame(11, (int)$tree[0]['subcategories'][0]['id']); } + + public function testFindByNameReturnsObjectWhenFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('get') + ->with('pp_shop_coupon', '*', ['name' => 'RABAT10']) + ->willReturn([ + 'id' => '5', + 'name' => 'RABAT10', + 'status' => '1', + 'used' => '0', + 'type' => '1', + 'amount' => '10.00', + 'one_time' => '1', + 'include_discounted_product' => '0', + 'categories' => '[1,2]', + 'used_count' => '0', + ]); + + $repository = new CouponRepository($mockDb); + $result = $repository->findByName('RABAT10'); + + $this->assertIsObject($result); + $this->assertSame(5, $result->id); + $this->assertSame('RABAT10', $result->name); + $this->assertSame(1, $result->status); + $this->assertSame(0, $result->used); + $this->assertSame(1, $result->type); + $this->assertSame('10.00', $result->amount); + $this->assertSame(1, $result->one_time); + $this->assertSame(0, $result->include_discounted_product); + $this->assertSame('[1,2]', $result->categories); + $this->assertSame(0, $result->used_count); + } + + public function testFindByNameReturnsNullWhenNotFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('get') + ->willReturn(null); + + $repository = new CouponRepository($mockDb); + $this->assertNull($repository->findByName('NIEISTNIEJACY')); + } + + public function testFindByNameReturnsNullForEmptyName(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->never())->method('get'); + + $repository = new CouponRepository($mockDb); + $this->assertNull($repository->findByName('')); + $this->assertNull($repository->findByName(' ')); + } + + public function testIsAvailableReturnsTrueForActiveCoupon(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new CouponRepository($mockDb); + + $coupon = (object)['id' => 1, 'status' => 1, 'used' => 0]; + $this->assertTrue($repository->isAvailable($coupon)); + } + + public function testIsAvailableReturnsFalseForUsedCoupon(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new CouponRepository($mockDb); + + $coupon = (object)['id' => 1, 'status' => 1, 'used' => 1]; + $this->assertFalse($repository->isAvailable($coupon)); + } + + public function testIsAvailableReturnsFalseForInactiveCoupon(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new CouponRepository($mockDb); + + $coupon = (object)['id' => 1, 'status' => 0, 'used' => 0]; + $this->assertFalse($repository->isAvailable($coupon)); + } + + public function testIsAvailableReturnsFalseForNullCoupon(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new CouponRepository($mockDb); + + $this->assertFalse($repository->isAvailable(null)); + } + + public function testIsAvailableWorksWithArray(): void + { + $mockDb = $this->createMock(\medoo::class); + $repository = new CouponRepository($mockDb); + + $this->assertTrue($repository->isAvailable(['id' => 1, 'status' => 1, 'used' => 0])); + $this->assertFalse($repository->isAvailable(['id' => 0, 'status' => 1, 'used' => 0])); + } + + public function testMarkAsUsedCallsUpdate(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('update') + ->willReturnCallback(function ($table, $row, $where) { + $this->assertSame('pp_shop_coupon', $table); + $this->assertSame(1, $row['used']); + $this->assertArrayHasKey('date_used', $row); + $this->assertSame(['id' => 10], $where); + }); + + $repository = new CouponRepository($mockDb); + $repository->markAsUsed(10); + } + + public function testMarkAsUsedSkipsInvalidId(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->never())->method('update'); + + $repository = new CouponRepository($mockDb); + $repository->markAsUsed(0); + } + + public function testIncrementUsedCountCallsUpdate(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('update') + ->willReturnCallback(function ($table, $row, $where) { + $this->assertSame('pp_shop_coupon', $table); + $this->assertSame(1, $row['used_count[+]']); + $this->assertSame(['id' => 7], $where); + }); + + $repository = new CouponRepository($mockDb); + $repository->incrementUsedCount(7); + } + + public function testIncrementUsedCountSkipsInvalidId(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->never())->method('update'); + + $repository = new CouponRepository($mockDb); + $repository->incrementUsedCount(0); + } } diff --git a/tests/Unit/Domain/Order/OrderRepositoryTest.php b/tests/Unit/Domain/Order/OrderRepositoryTest.php index 31fc212..98cac9b 100644 --- a/tests/Unit/Domain/Order/OrderRepositoryTest.php +++ b/tests/Unit/Domain/Order/OrderRepositoryTest.php @@ -91,4 +91,141 @@ class OrderRepositoryTest extends TestCase $this->assertSame(2, $result['items'][0]['total_orders']); $this->assertSame(1, $result['items'][0]['paid']); } + + // --- Frontend method tests --- + + public function testFindIdByHashReturnsIdWhenFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get') + ->with('pp_shop_orders', 'id', ['hash' => 'abc123']) + ->willReturn('42'); + + $repository = new OrderRepository($mockDb); + $this->assertSame(42, $repository->findIdByHash('abc123')); + } + + public function testFindIdByHashReturnsNullWhenNotFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(null); + + $repository = new OrderRepository($mockDb); + $this->assertNull($repository->findIdByHash('nonexistent')); + } + + public function testFindIdByHashReturnsNullForEmptyHash(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->never())->method('get'); + + $repository = new OrderRepository($mockDb); + $this->assertNull($repository->findIdByHash('')); + $this->assertNull($repository->findIdByHash(' ')); + } + + public function testFindHashByIdReturnsHashWhenFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get') + ->with('pp_shop_orders', 'hash', ['id' => 42]) + ->willReturn('abc123hash'); + + $repository = new OrderRepository($mockDb); + $this->assertSame('abc123hash', $repository->findHashById(42)); + } + + public function testFindHashByIdReturnsNullForInvalidId(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->expects($this->never())->method('get'); + + $repository = new OrderRepository($mockDb); + $this->assertNull($repository->findHashById(0)); + $this->assertNull($repository->findHashById(-1)); + } + + public function testOrderDetailsFrontendByIdReturnsArrayWithProducts(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get') + ->willReturn(['id' => 1, 'number' => '2026/02/001', 'coupon_id' => null]); + $mockDb->method('select') + ->willReturn([['product_id' => 10, 'name' => 'Test Product']]); + + $repository = new OrderRepository($mockDb); + $result = $repository->orderDetailsFrontend(1); + + $this->assertIsArray($result); + $this->assertSame(1, $result['id']); + $this->assertIsArray($result['products']); + $this->assertCount(1, $result['products']); + } + + public function testOrderDetailsFrontendByHashReturnsArrayWithProducts(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get') + ->willReturn(['id' => 5, 'number' => '2026/02/005', 'hash' => 'testhash']); + $mockDb->method('select') + ->willReturn([]); + + $repository = new OrderRepository($mockDb); + $result = $repository->orderDetailsFrontend(null, 'testhash'); + + $this->assertIsArray($result); + $this->assertSame(5, $result['id']); + $this->assertIsArray($result['products']); + } + + public function testOrderDetailsFrontendReturnsNullWhenNotFound(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(null); + + $repository = new OrderRepository($mockDb); + $this->assertNull($repository->orderDetailsFrontend(999)); + $this->assertNull($repository->orderDetailsFrontend(null, 'nonexistent')); + } + + public function testGenerateOrderNumberFormatsCorrectly(): void + { + $mockDb = $this->createMock(\medoo::class); + + $resultSet = new class { + public function fetchAll(): array + { + return [[5]]; + } + }; + + $mockDb->method('query')->willReturn($resultSet); + + $repository = new OrderRepository($mockDb); + $number = $repository->generateOrderNumber(); + + $expectedPrefix = date('Y/m') . '/'; + $this->assertStringStartsWith($expectedPrefix, $number); + $this->assertSame($expectedPrefix . '006', $number); + } + + public function testGenerateOrderNumberStartsAt001(): void + { + $mockDb = $this->createMock(\medoo::class); + + $resultSet = new class { + public function fetchAll(): array + { + return [[null]]; + } + }; + + $mockDb->method('query')->willReturn($resultSet); + + $repository = new OrderRepository($mockDb); + $number = $repository->generateOrderNumber(); + + $expectedPrefix = date('Y/m') . '/'; + $this->assertSame($expectedPrefix . '001', $number); + } } diff --git a/tests/Unit/front/Controllers/ShopCouponControllerTest.php b/tests/Unit/front/Controllers/ShopCouponControllerTest.php new file mode 100644 index 0000000..79cb8d9 --- /dev/null +++ b/tests/Unit/front/Controllers/ShopCouponControllerTest.php @@ -0,0 +1,40 @@ +repository = $this->createMock(CouponRepository::class); + $this->controller = new ShopCouponController($this->repository); + } + + public function testConstructorAcceptsRepository(): void + { + $controller = new ShopCouponController($this->repository); + $this->assertInstanceOf(ShopCouponController::class, $controller); + } + + public function testHasMainActionMethods(): void + { + $this->assertTrue(method_exists($this->controller, 'useCoupon')); + $this->assertTrue(method_exists($this->controller, 'deleteCoupon')); + } + + public function testConstructorRequiresCouponRepository(): void + { + $reflection = new \ReflectionClass(ShopCouponController::class); + $constructor = $reflection->getConstructor(); + $params = $constructor->getParameters(); + + $this->assertCount(1, $params); + $this->assertEquals('Domain\Coupon\CouponRepository', $params[0]->getType()->getName()); + } +} diff --git a/tests/Unit/front/Controllers/ShopOrderControllerTest.php b/tests/Unit/front/Controllers/ShopOrderControllerTest.php new file mode 100644 index 0000000..2c54ef6 --- /dev/null +++ b/tests/Unit/front/Controllers/ShopOrderControllerTest.php @@ -0,0 +1,43 @@ +repository = $this->createMock(OrderRepository::class); + $this->controller = new ShopOrderController($this->repository); + } + + public function testConstructorAcceptsRepository(): void + { + $controller = new ShopOrderController($this->repository); + $this->assertInstanceOf(ShopOrderController::class, $controller); + } + + public function testHasMainActionMethods(): void + { + $this->assertTrue(method_exists($this->controller, 'paymentConfirmation')); + $this->assertTrue(method_exists($this->controller, 'paymentStatusTpay')); + $this->assertTrue(method_exists($this->controller, 'paymentStatusPrzelewy24pl')); + $this->assertTrue(method_exists($this->controller, 'paymentStatusHotpay')); + $this->assertTrue(method_exists($this->controller, 'orderDetails')); + } + + public function testConstructorRequiresOrderRepository(): void + { + $reflection = new \ReflectionClass(ShopOrderController::class); + $constructor = $reflection->getConstructor(); + $params = $constructor->getParameters(); + + $this->assertCount(1, $params); + $this->assertEquals('Domain\Order\OrderRepository', $params[0]->getType()->getName()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9b3d592..44915d8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,7 @@ if (file_exists(__DIR__ . '/../vendor/autoload.php')) { $prefixes = [ 'Domain\\' => __DIR__ . '/../autoload/Domain/', 'admin\\Controllers\\' => __DIR__ . '/../autoload/admin/Controllers/', + 'front\\Controllers\\' => __DIR__ . '/../autoload/front/Controllers/', 'admin\\Support\\Forms\\' => __DIR__ . '/../autoload/admin/Support/Forms/', 'admin\\ViewModels\\Forms\\' => __DIR__ . '/../autoload/admin/ViewModels/Forms/', 'admin\\Validation\\' => __DIR__ . '/../autoload/admin/Validation/', diff --git a/updates/0.20/ver_0.290.zip b/updates/0.20/ver_0.290.zip new file mode 100644 index 0000000000000000000000000000000000000000..3577bf8c454d34e86f96d4bb19778abdacc38952 GIT binary patch literal 36607 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 literal 0 HcmV?d00001 diff --git a/updates/0.20/ver_0.290_files.txt b/updates/0.20/ver_0.290_files.txt new file mode 100644 index 0000000..5c689ac --- /dev/null +++ b/updates/0.20/ver_0.290_files.txt @@ -0,0 +1,5 @@ +F: ../autoload/front/controls/class.ShopCoupon.php +F: ../autoload/front/factory/class.ShopCoupon.php +F: ../autoload/front/controls/class.ShopOrder.php +F: ../autoload/front/factory/class.ShopOrder.php +F: ../autoload/front/view/class.ShopOrder.php diff --git a/updates/changelog.php b/updates/changelog.php index 0f016ba..7152c47 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,3 +1,9 @@ +ver. 0.290 - 17.02.2026
+- UPDATE - migracja front\factory\ShopCoupon + front\controls\ShopCoupon do Domain\Coupon\CouponRepository + front\Controllers\ShopCouponController +- UPDATE - migracja front\factory\ShopOrder + front\controls\ShopOrder + front\view\ShopOrder do Domain\Order\OrderRepository + front\Controllers\ShopOrderController +- FIX - kupony jednorazowe nigdy nie byly oznaczane jako uzyte (is_one_time/set_as_used w shop\Coupon) +- FIX - webhooks przelewy24/hotpay ujednolicone z tpay (poprawna obsluga Apilo sync) +
ver. 0.289 - 17.02.2026
- UPDATE - migracja front\factory\ShopCategory + front\view\ShopCategory do Domain\Category\CategoryRepository + front\Views\ShopCategory - UPDATE - migracja front\factory\ShopClient + front\view\ShopClient + front\controls\ShopClient do Domain\Client\ClientRepository + front\Views\ShopClient + front\Controllers\ShopClientController diff --git a/updates/versions.php b/updates/versions.php index 911755b..39c910d 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@