From 858d682cdea5c570d04c463b4eedc3be3be782a6 Mon Sep 17 00:00:00 2001 From: Dmitriy Date: Fri, 16 Jan 2026 16:48:48 +0300 Subject: [PATCH] =?UTF-8?q?Dmitriy=20|=20=D0=98=D0=BD=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + assets/css/options-page.css | 62 ++++++ assets/css/price-updates-page.css | 181 ++++++++++++++++ assets/js/options-page.js | 69 ++++++ assets/js/price-updates-page.js | 205 ++++++++++++++++++ composer.json | 24 ++ config/google/credentials.json | 13 ++ price-updates.php | 38 ++++ src/Admin/Menu.php | 17 ++ src/Admin/OptionsSubMenu.php | 45 ++++ src/Admin/PriceUpdatesMenu.php | 48 ++++ src/Classes/ParseProduct.php | 61 ++++++ src/Classes/ParseProductError.php | 45 ++++ src/Controllers/AbstractController.php | 21 ++ .../Interface/RegisterControllerInterface.php | 7 + src/Controllers/OptionsController.php | 55 +++++ src/Controllers/PriceUpdatesController.php | 118 ++++++++++ src/Parser/AbstractParser.php | 16 ++ src/Parser/ExcelParser.php | 55 +++++ src/Parser/GoogleTableParser.php | 103 +++++++++ src/Parser/Interface/ParserInterface.php | 7 + src/RestApi.php | 26 +++ src/Services/OptionsService.php | 27 +++ src/Services/WCPriceUpdate.php | 76 +++++++ src/Utils/GoogleAuthConfigValidator.php | 31 +++ src/Views/OptionsPage.php | 57 +++++ src/Views/PriceUpdatesPage.php | 55 +++++ 27 files changed, 1464 insertions(+) create mode 100644 .gitignore create mode 100644 assets/css/options-page.css create mode 100644 assets/css/price-updates-page.css create mode 100644 assets/js/options-page.js create mode 100644 assets/js/price-updates-page.js create mode 100644 composer.json create mode 100644 config/google/credentials.json create mode 100644 price-updates.php create mode 100644 src/Admin/Menu.php create mode 100644 src/Admin/OptionsSubMenu.php create mode 100644 src/Admin/PriceUpdatesMenu.php create mode 100644 src/Classes/ParseProduct.php create mode 100644 src/Classes/ParseProductError.php create mode 100644 src/Controllers/AbstractController.php create mode 100644 src/Controllers/Interface/RegisterControllerInterface.php create mode 100644 src/Controllers/OptionsController.php create mode 100644 src/Controllers/PriceUpdatesController.php create mode 100644 src/Parser/AbstractParser.php create mode 100644 src/Parser/ExcelParser.php create mode 100644 src/Parser/GoogleTableParser.php create mode 100644 src/Parser/Interface/ParserInterface.php create mode 100644 src/RestApi.php create mode 100644 src/Services/OptionsService.php create mode 100644 src/Services/WCPriceUpdate.php create mode 100644 src/Utils/GoogleAuthConfigValidator.php create mode 100644 src/Views/OptionsPage.php create mode 100644 src/Views/PriceUpdatesPage.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88e99d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +composer.lock \ No newline at end of file diff --git a/assets/css/options-page.css b/assets/css/options-page.css new file mode 100644 index 0000000..847a6c3 --- /dev/null +++ b/assets/css/options-page.css @@ -0,0 +1,62 @@ +.price-updates-options { + position: relative; + font-size: 16px; +} + +.price-updates-options input, +.price-updates-options label, +.price-updates-options button { + font-size: 16px; +} + + +.price-updates-options__input-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-bottom: 12px; +} + +.price-updates-options__input-wrapper input[type="text"] { + width: 500px; +} + +.price-updates-options__submit-wrapper { + display: flex; + gap: 12px; +} + +.price-updates-options__submit { + width: 200px; + padding: 12px 16px; + text-align: center; + border-radius: 20px; + border: none; + color: #fff; + background: radial-gradient(278.91% 196.13% at 128.36% -48.29%, #ee6868 0%, #569ef0 57.69%); + cursor: pointer; + transition: filter 0.3s ease; + margin-top: 8px; +} + +.price-updates-options__submit:hover { + filter: brightness(0.9); +} + +.price-updates-options__success-message, +.price-updates-options__error-message { + width: max-content; + padding: 16px 20px; + border-radius: 8px; +} + +.price-updates-options__success-message { + color: #fff; + background-color: #19a917; +} + +.price-updates-options__error-message { + color: #fff; + background-color: #fa0505; +} \ No newline at end of file diff --git a/assets/css/price-updates-page.css b/assets/css/price-updates-page.css new file mode 100644 index 0000000..cd8c0cb --- /dev/null +++ b/assets/css/price-updates-page.css @@ -0,0 +1,181 @@ +.hidden { + display: none !important; +} + +.price-updates-wrapper { + margin-right: 20px; + padding: 12px; + position: relative; + font-size: 16px; +} + +.price-updates-wrapper input, +.price-updates-wrapper label, +.price-updates-wrapper button { + font-size: 16px; +} + + +.price-updates-variations { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-bottom: 18px; +} + +.price-updates-contents { + margin-bottom: 12px; +} + +.price-updates-contents form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +.price-updates-contents input { + height: 30px; +} + +.price-updates-contents input[type="url"] { + width: 600px; +} + +.price-updates-contents button { + width: 200px; + padding: 12px 16px; + text-align: center; + border-radius: 20px; + border: none; + color: #fff; + background: radial-gradient(278.91% 196.13% at 128.36% -48.29%, #ee6868 0%, #569ef0 57.69%); + cursor: pointer; + transition: filter 0.3s ease; +} + +.price-updates-contents button:hover { + filter: brightness(0.9); +} + +.price-updates-caption { + display: block; + margin-bottom: 12px; + font-size: 14px; +} + +.price-updates-count { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +.price-updates-count-success span { + color: #19a917; +} + +.price-updates-count-error span { + color: #fa0505; +} + +.price-updates-response { + width: 100%; + max-height: 800px; + overflow-y: auto; + margin-bottom: 12px; +} + +.price-updates-response table { + width: 100%; +} + +.price-updates-response thead th { + position: sticky; + top: 0; + padding-block: 12px; + background-color: #f0f0f1; +} + +.price-updates-response tbody td { + text-align: center; + padding-block: 12px; +} + +.price-updates-error { + width: max-content; + padding: 16px 20px; + border-radius: 8px; + color: #fff; + background-color: #fa0505; +} + +.price-updates-loader-wrapper { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + position: absolute; + top: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.25); +} + +.price-updates-loader { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 6rem; + margin-top: 3rem; + margin-bottom: 3rem; +} + +.price-updates-loader:before, +.price-updates-loader:after { + content: ""; + position: absolute; + border-radius: 50%; + animation: pulsOut 1.8s ease-in-out infinite; + filter: drop-shadow(0 0 1rem rgba(255, 255, 255, 0.75)); +} + +.price-updates-loader:before { + width: 100%; + padding-bottom: 100%; + box-shadow: inset 0 0 0 1rem #fff; + animation-name: pulsIn; +} + +.price-updates-loader:after { + width: calc(100% - 2rem); + padding-bottom: calc(100% - 2rem); + box-shadow: 0 0 0 0 #fff; +} + +@keyframes pulsIn { + 0% { + box-shadow: inset 0 0 0 1rem #fff; + opacity: 1; + } + + 50%, 100% { + box-shadow: inset 0 0 0 0 #fff; + opacity: 0; + } +} + +@keyframes pulsOut { + 0%, 50% { + box-shadow: 0 0 0 0 #fff; + opacity: 0; + } + + 100% { + box-shadow: 0 0 0 1rem #fff; + opacity: 1; + } +} \ No newline at end of file diff --git a/assets/js/options-page.js b/assets/js/options-page.js new file mode 100644 index 0000000..21b8926 --- /dev/null +++ b/assets/js/options-page.js @@ -0,0 +1,69 @@ +jQuery(document).ready(function ($) { + const $form = $(".price-updates-options__form"); + const $loaderWrapper = $(".price-updates-loader-wrapper"); + + const $success = $(".price-updates-options__success-message"); + const $error = $(".price-updates-options__error-message"); + + function hideMessages() { + $success.addClass("hidden"); + $error.addClass("hidden") + } + + // [Событие] Сохранение настроек + $form.on("submit", function (e) { + e.preventDefault(); + + $loaderWrapper.removeClass("hidden"); + + $.ajax({ + method: "POST", + url: priceUpdatesOptionsSettings.ajaxUrl + "/update", + data: $(this).serializeArray(), + success: function (response) { + hideMessages(); + + $success.removeClass("hidden"); + $success.text("Настройки обновлены."); + }, + error: function (error) { + console.error(error) + + hideMessages(); + + $error.removeClass("hidden"); + $error.text("Ошибка при обновлении настроек."); + }, + complete: function () { + $loaderWrapper.addClass("hidden"); + } + }) + }) + + $loaderWrapper.removeClass("hidden"); + + // [Ajax] Подгружаем настройки + $.ajax({ + method: "GET", + url: priceUpdatesOptionsSettings.ajaxUrl + "/get", + success: function (response) { + if (!response) return; + + for (const [key, value] of Object.entries(response)) { + const $input = $form.find(`input[name="${key}"]`); + $input.val(JSON.stringify(value).slice(1, -1)); + } + }, + error: function (error) { + console.error(error); + + hideMessages(); + + $error.removeClass("hidden"); + $error.text("Ошибка при загрузке настроек."); + }, + complete: function () { + $loaderWrapper.addClass("hidden"); + } + }) +}); \ No newline at end of file diff --git a/assets/js/price-updates-page.js b/assets/js/price-updates-page.js new file mode 100644 index 0000000..cd0dd20 --- /dev/null +++ b/assets/js/price-updates-page.js @@ -0,0 +1,205 @@ +jQuery(document).ready(function ($) { + const $variantons = $('input[name="price-updates-variant"]'); + const $contents = $("form[data-price-updates-variant]"); + + const $count = $(".price-updates-count"); + const $countSuccess = $(".price-updates-count-success"); + const $countError = $(".price-updates-count-error"); + + const $tableWrapper = $(".price-updates-response"); + const $error = $(".price-updates-error"); + const $loaderWrapper = $(".price-updates-loader-wrapper"); + + // [Событие] Смена варианита обновления цен + $variantons.on("change", function () { + const $varianton = $(this); + + $tableWrapper.find("tbody").html(""); + $tableWrapper.addClass("hidden"); + + updatePriceUpdatesCountSuccess(0); + updatePriceUpdatesCountError(0); + + $count.addClass("hidden"); + $countSuccess.addClass("hidden"); + $countError.addClass("hidden"); + + $contents.each(function() { + const $content = $(this); + + if ($varianton.val() != $content.data("price-updates-variant")) { + $content.addClass("hidden"); + } else { + $content.removeClass("hidden"); + } + }); + }); + + // Инициализация вариантов обновления цен + $variantons.each(function() { + const $varianton = $(this); + + $contents.each(function() { + const $content = $(this); + + if (!$varianton.is(":checked") && $varianton.val() == $content.data("price-updates-variant")) { + $content.addClass("hidden"); + } else { + $content.removeClass("hidden"); + } + }); + }); + + function getCount(response) { + if (!response?.length) { + return 0; + } + + let success = 0; + let error = 0; + + for (const item of response) { + if (!item?.isError) { + success += 1; + } else { + error += 1; + } + } + + return { success, error }; + } + + function updatePriceUpdatesCountSuccess(count) { + if (typeof count !== "number") return; + + $count.removeClass("hidden"); + + $countSuccess.removeClass("hidden"); + $countSuccess.html(`Измененно элементов: ${count}`); + } + + function updatePriceUpdatesCountError(count) { + if (typeof count !== "number") return; + + $count.removeClass("hidden"); + + $countError.removeClass("hidden"); + $countError.html(`Неудалось изменить элементов: ${count}`); + } + + function getTableRow(data) { + const row = document.createElement("tr"); + + const tempData = { ...data }; + + delete tempData.url; + delete tempData.currency; + + for (const [key, value] of Object.entries(tempData)) { + const ceil = document.createElement("td"); + + if (key === "sku") { + const link = document.createElement("a"); + + link.href = data.url; + link.textContent = value; + + ceil.appendChild(link); + } else if (key === "regular" || key === "sale") { + ceil.textContent = value["old"] != value["new"] ? `${value["old"]} ${data.currency} -> ${value["new"]} ${data.currency}` : `${value["new"]} ${data.currency}`; + } + + row.appendChild(ceil); + } + + return $(row); + } + + function getTableRowError(data) { + const row = document.createElement("tr"); + + const tempData = { + sku: data?.sku, + message: data?.message + }; + + for (const [key, value] of Object.entries(tempData)) { + const ceil = document.createElement("td"); + + ceil.textContent = value; + + if (key === "message") { + ceil.colSpan = 2; + } + + row.appendChild(ceil); + } + + return $(row); + } + + // [Событие] Отправка формы + $contents.on("submit", function(e) { + e.preventDefault(); + + $loaderWrapper.removeClass("hidden"); + + $.ajax({ + method: "POST", + url: priceUpdatesSettings.ajaxUrl + "/update", + processData: false, + contentType: false, + data: new FormData(this), + success: function (response) { + $error.addClass("hidden"); + + $tableWrapper.find("tbody").html(""); + + if (!response?.length) { + $tableWrapper.addClass("hidden"); + + updatePriceUpdatesCountSuccess(0); + updatePriceUpdatesCountError(0); + + return; + } + + const count = getCount(response); + + updatePriceUpdatesCountSuccess(count.success); + updatePriceUpdatesCountError(count.error); + + $tableWrapper.removeClass("hidden"); + + for (const item of response) { + let $row = null; + + if (!item?.isError) { + $row = getTableRow(item); + } else { + $row = getTableRowError(item); + } + + $tableWrapper.find("tbody").append($row); + } + }, + error: function (error) { + $tableWrapper.find("tbody").html(""); + $tableWrapper.addClass("hidden"); + + updatePriceUpdatesCountSuccess(0); + updatePriceUpdatesCountError(0); + + $count.addClass("hidden"); + $countSuccess.addClass("hidden"); + $countError.addClass("hidden"); + + $error.removeClass("hidden"); + $error.text(error?.responseJSON?.message || "Неизвестная ошибка"); + }, + complete: function () { + $loaderWrapper.addClass("hidden"); + } + }) + }) +}); \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ec53765 --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "cosmopet/price-updates", + "description": "Update price for products", + "autoload": { + "psr-4": { + "Cosmopet\\PriceUpdates\\": "src/" + } + }, + "authors": [ + { + "name": "Dmitriy", + "email": "Quali123@list.ru" + } + ], + "config": { + "platform": { + "php": "8.0" + } + }, + "require": { + "shuchkin/simplexlsx": "^1.1", + "google/apiclient": "^2.18" + } +} diff --git a/config/google/credentials.json b/config/google/credentials.json new file mode 100644 index 0000000..643d412 --- /dev/null +++ b/config/google/credentials.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "", + "private_key_id": "", + "private_key": "", + "client_email": "", + "client_id": "", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/gs-648%40weighty-forest-484418-g1.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} \ No newline at end of file diff --git a/price-updates.php b/price-updates.php new file mode 100644 index 0000000..a5df093 --- /dev/null +++ b/price-updates.php @@ -0,0 +1,38 @@ + require_once(PRICE_UPDATES_PLUGIN_DIR . "/src/Views/OptionsPage.php"), + 1 + ); + } + + public static function enqueue($hook) { + if (!str_contains($hook, self::$slug)) return; + + wp_enqueue_style( + self::$slug . "-style", + PRICE_UPDATES_PLUGIN_URL . "assets/css/options-page.css", + [], + filemtime(PRICE_UPDATES_PLUGIN_DIR . '/assets/css/options-page.css') + ); + + wp_enqueue_script( + self::$slug . "-script", + PRICE_UPDATES_PLUGIN_URL . "assets/js/options-page.js", + ["jquery"], + filemtime(PRICE_UPDATES_PLUGIN_DIR . '/assets/js/options-page.js'), + true + ); + + wp_localize_script(self::$slug . "-script", "priceUpdatesOptionsSettings", [ + "ajaxUrl" => RestApi::getAjaxUrl() . OptionsController::getNamespace(), + ]); + } +} \ No newline at end of file diff --git a/src/Admin/PriceUpdatesMenu.php b/src/Admin/PriceUpdatesMenu.php new file mode 100644 index 0000000..7f8959f --- /dev/null +++ b/src/Admin/PriceUpdatesMenu.php @@ -0,0 +1,48 @@ + require_once(PRICE_UPDATES_PLUGIN_DIR . "/src/Views/PriceUpdatesPage.php"), + "dashicons-cloud", + 76 + ); + } + + public static function enqueue($hook) { + if (!str_contains($hook, self::$slug)) return; + + wp_enqueue_style( + self::$slug . "-style", + PRICE_UPDATES_PLUGIN_URL . "assets/css/price-updates-page.css", + [], + filemtime(PRICE_UPDATES_PLUGIN_DIR . '/assets/css/price-updates-page.css') + ); + + wp_enqueue_script( + self::$slug . "-script", + PRICE_UPDATES_PLUGIN_URL . "assets/js/price-updates-page.js", + ["jquery"], + filemtime(PRICE_UPDATES_PLUGIN_DIR . '/assets/js/price-updates-page.js'), + true + ); + + wp_localize_script(self::$slug . "-script", "priceUpdatesSettings", [ + "ajaxUrl" => RestApi::getAjaxUrl(), + ]); + } + + public static function getSlug(): string { + return self::$slug; + } +} \ No newline at end of file diff --git a/src/Classes/ParseProduct.php b/src/Classes/ParseProduct.php new file mode 100644 index 0000000..1331d9a --- /dev/null +++ b/src/Classes/ParseProduct.php @@ -0,0 +1,61 @@ +setSku($sku); + $this->setPrice($price); + } + + public static function validateSku($sku): bool { + if (gettype($sku) !== "string") { + return false; + } + + return true; + } + + public static function validatePrice($price): bool { + if (!is_numeric($price)) { + return false; + } + + return true; + } + + public function getSku(): string { + return $this->sku; + } + + public function setSku(string $sku): ParseProduct { + $this->sku = $sku; + return $this; + } + + public function getPrice(): int { + return $this->price; + } + + public function setPrice(int $price): ParseProduct { + $this->price = $price; + return $this; + } + + public function toArray(): array { + return [ + "sku" => $this->getSku(), + "price" => $this->getPrice(), + ]; + } +} \ No newline at end of file diff --git a/src/Classes/ParseProductError.php b/src/Classes/ParseProductError.php new file mode 100644 index 0000000..e2a1dc8 --- /dev/null +++ b/src/Classes/ParseProductError.php @@ -0,0 +1,45 @@ +setSku($sku); + $this->setMessage($message); + } + + public function getSku(): string { + return $this->sku; + } + + public function setSku(string $sku): ParseProductError { + $this->sku = $sku; + return $this; + } + + public function getMessage(): string { + return $this->message; + } + + public function setMesage(string $message): ParseProductError { + $this->message = $message; + return $this; + } + + public function toArray(): array { + return [ + "sku" => $this->getSku(), + "message" => $this->getMessage(), + ]; + } +} \ No newline at end of file diff --git a/src/Controllers/AbstractController.php b/src/Controllers/AbstractController.php new file mode 100644 index 0000000..b870b03 --- /dev/null +++ b/src/Controllers/AbstractController.php @@ -0,0 +1,21 @@ + "/get/", + "methods" => "GET", + "callback" => ["\Cosmopet\PriceUpdates\Controllers\OptionsController", "get"], + ], + [ + "uri" => "/update/", + "methods" => "POST", + "callback" => ["\Cosmopet\PriceUpdates\Controllers\OptionsController", "update"], + ], + ]; + + public static function get() { + $response = OptionsService::get(); + + if (isset($data["error"])) { + wp_send_json([ + "error" => true, + "message" => $data["error"], + ], 404); + + wp_die(); + } + + wp_send_json($response, 200, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + + public static function update() { + $result = OptionsService::update($_POST); + + if (OptionsService::update($_POST) === false) { + wp_send_json([ + "error" => true, + "message" => "Ошибка сохранения настроек.", + ], 400); + + wp_die(); + } + + GoogleTableParser::init(); + + wp_send_json($result, 200); + } +} \ No newline at end of file diff --git a/src/Controllers/PriceUpdatesController.php b/src/Controllers/PriceUpdatesController.php new file mode 100644 index 0000000..8fc886f --- /dev/null +++ b/src/Controllers/PriceUpdatesController.php @@ -0,0 +1,118 @@ + "/update/", + "methods" => "POST", + "callback" => ["\Cosmopet\PriceUpdates\Controllers\PriceUpdatesController", "update"], + ], + ]; + + public static function update() { + if (!isset($_POST["type"])){ + wp_send_json([ + "error" => true, + "message" => "Тип обнвления отсутствует.", + ], 400); + + wp_die(); + } + + switch ($_POST["type"]) { + case "excel": + if (!isset($_FILES["file"])) { + wp_send_json([ + "error" => true, + "message" => "Файл отсутствует.", + ], 400); + + wp_die(); + } + + $fileExtension = strtolower(pathinfo($_FILES["file"]["name"], PATHINFO_EXTENSION)); + + if (!in_array($fileExtension, self::$allowedExtensions)) { + wp_send_json([ + "error" => true, + "message" => "Разрешены только файлы с типом XLX или XLXS.", + ], 400); + + wp_die(); + } + + $data = ExcelParser::parse($_FILES["file"]); + + if (isset($data["error"])) { + wp_send_json([ + "error" => true, + "message" => $data["error"], + ], 400); + + wp_die(); + } + + wp_send_json(WCPriceUpdate::update($data), 200, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + break; + + case "google-table": + if (!isset($_POST["url"])){ + wp_send_json([ + "error" => true, + "message" => "Нобходимо указать ссылку.", + ], 400); + + wp_die(); + } + + $url = $_POST["url"]; + + if (gettype($url) !== "string"){ + wp_send_json([ + "error" => true, + "message" => "Неверный тип ссылки.", + ], 400); + + wp_die(); + } + + if (!GoogleTableParser::isValidGoogleSheetsUrl($url)){ + wp_send_json([ + "error" => true, + "message" => "Ссылка должна вести на гугл-таблицу.", + ], 400); + + wp_die(); + } + + $data = GoogleTableParser::parse($url); + + if (isset($data["error"])) { + wp_send_json([ + "error" => true, + "message" => $data["error"], + ], !empty($data["code"]) ? $data["code"] : 400); + + wp_die(); + } + + wp_send_json(WCPriceUpdate::update($data), 200, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + break; + } + } + + public static function can() { + return current_user_can("manage_options"); + } +} \ No newline at end of file diff --git a/src/Parser/AbstractParser.php b/src/Parser/AbstractParser.php new file mode 100644 index 0000000..635cc7d --- /dev/null +++ b/src/Parser/AbstractParser.php @@ -0,0 +1,16 @@ + false ]); + + if (!isset($file["file"])) { + return [ + "error" => "Ошибка обработки файла.", + ]; + } + + $parse = SimpleXLSX::parse($file["file"]); + + unlink($file["file"]); + + if ($parse === false) { + return [ + "error" => SimpleXLSX::parseError(), + ]; + } + + $result = []; + + foreach($parse->rows() as $index => $row) { + if ($index === 0) continue; + + $sku = $row[0]; + $price = intval($row[1]); + + if (!ParseProduct::validateSku($sku)) { + $ceilNumber = $index + 1; + + $result[] = new ParseProductError("Номер строки: $ceilNumber", "Неверный артикул товара."); + + continue; + } + + if (!ParseProduct::validatePrice($price)) { + $result[] = new ParseProductError($sku, "Неверный формат цены."); + continue; + } + + $result[] = new ParseProduct($sku, $price); + } + + return $result; + } +} \ No newline at end of file diff --git a/src/Parser/GoogleTableParser.php b/src/Parser/GoogleTableParser.php new file mode 100644 index 0000000..798fb94 --- /dev/null +++ b/src/Parser/GoogleTableParser.php @@ -0,0 +1,103 @@ +setApplicationName('Google Sheets API'); + $client->setScopes([\Google_Service_Sheets::SPREADSHEETS]); + $client->setAccessType('offline'); + + $file = file_get_contents(OptionsService::$filePath); + $data = json_decode($file, true); + + if (GoogleAuthConfigValidator::validate($data)) { + $client->setAuthConfig(OptionsService::$filePath); + } + + self::$service = new \Google_Service_Sheets($client); + } + + public static function parse($data): array { + preg_match('/\/d\/([a-zA-Z0-9-_]+)/', $data, $matches); + + $spreadsheetId = $matches[1]; + + if (empty($spreadsheetId)) { + return [ + "error" => "ID Гугл таблицы отсутствует.", + ]; + } + + try { + $rows = self::$service->spreadsheets_values->get($spreadsheetId, "A:B")?->values; + + if (count($rows) === 0) return []; + + $result = []; + + foreach($rows as $index => $row) { + if ($index === 0) continue; + + $sku = $row[0]; + $price = intval($row[1]); + + if (!ParseProduct::validateSku($sku)) { + $ceilNumber = $index + 1; + + $result[] = new ParseProductError("Номер строки: $ceilNumber", "Неверный артикул товара."); + + continue; + } + + if (!ParseProduct::validatePrice($price)) { + $result[] = new ParseProductError($sku, "Неверный формат цены."); + continue; + } + + $result[] = new ParseProduct($sku, $price); + } + + return $result; + } catch (\Throwable $e) { + $error = json_decode($e->getMessage()); + + return [ + "code" => !empty($error?->error?->code) ? $error?->error?->code : 400, + "error" => !empty($error?->error?->message) ? $error?->error?->message : "Ошибка при обработке гугл-таблицы.", + ]; + } + } + + public static function isValidGoogleSheetsUrl(string $url): bool { + $patterns = [ + // Стандартные ссылки на таблицы + '~^https?://docs\.google\.com/spreadsheets/(?:d|u/\d+)/([a-zA-Z0-9-_]+)~i', + + // Ссылки через Google Drive + '~^https?://drive\.google\.com/(?:open\?.*id=|file/d/)([a-zA-Z0-9-_]+).*[?&]usp=sheets~i', + + // Ссылки на публичные таблицы + '~^https?://docs\.google\.com/spreadsheets/d/e/[a-zA-Z0-9-_]+/pub~i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $url)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/Parser/Interface/ParserInterface.php b/src/Parser/Interface/ParserInterface.php new file mode 100644 index 0000000..7047683 --- /dev/null +++ b/src/Parser/Interface/ParserInterface.php @@ -0,0 +1,7 @@ + "Файл не найден" + ]; + } + + return json_decode($file, true); + } + + public static function update($data) { + $data = array_map(fn ($val) => stripcslashes($val), $data); + $data = stripcslashes(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + return file_put_contents(self::$filePath, $data); + } +} \ No newline at end of file diff --git a/src/Services/WCPriceUpdate.php b/src/Services/WCPriceUpdate.php new file mode 100644 index 0000000..5471735 --- /dev/null +++ b/src/Services/WCPriceUpdate.php @@ -0,0 +1,76 @@ +toArray(), [ "isError" => true ]); + continue; + } + + $products = wc_get_products([ "sku" => $item->getSku() ]); + + if (count($products) === 0) { + $result[] = array_merge($item->toArray(), [ "message" => "Товар не найден, проверьте артикул.", "isError" => true ]); + continue; + } + + $product = $products[0]; + + $price = [ + "regular" => (int) $product->get_regular_price(), + "sale" => (int) $product->get_sale_price(), + "new" => $item->getPrice(), + ]; + + // Если цена товара равна базовой и акционная цена отсутствует, его нет смысла обновлять, т.к. данные везде совпадают + if ($price["regular"] === $price["new"] && $price["sale"] === 0) { + $result[] = array_merge($item->toArray(), $price, [ "message" => "Товар не обновлён. Цены совпадают.", "isError" => true ]); + continue; + } + + // Если цена товара меньше базовой, НО равна акционной, его нет смысла обновлять, ибо ничего не обновиться + if ($price["sale"] === $price["new"]) { + $result[] = array_merge($item->toArray(), $price, [ "message" => "Товар не обновлён. Акционные цены совпадают.", "isError" => true ]); + continue; + } + + if ($price["regular"] <= $price["new"]) { + $product->set_regular_price($price["new"]); + + if ($price["sale"] > 0) { + $product->set_sale_price(0); + } + } else { + $product->set_sale_price($price["new"]); + } + + + $product->save(); + + $result[] = [ + "sku" => $item->getSku(), + "url" => $product->get_permalink(), + "regular" => [ + "new" => (int) $product->get_regular_price(), + "old" => $price["regular"], + ], + "sale" => [ + "new" => (int) $product->get_sale_price(), + "old" => $price["sale"] + ], + "currency" => get_woocommerce_currency(), + ]; + } + + return $result; + } +} \ No newline at end of file diff --git a/src/Utils/GoogleAuthConfigValidator.php b/src/Utils/GoogleAuthConfigValidator.php new file mode 100644 index 0000000..ed873f5 --- /dev/null +++ b/src/Utils/GoogleAuthConfigValidator.php @@ -0,0 +1,31 @@ + +

Настройки

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + \ No newline at end of file diff --git a/src/Views/PriceUpdatesPage.php b/src/Views/PriceUpdatesPage.php new file mode 100644 index 0000000..dc19d24 --- /dev/null +++ b/src/Views/PriceUpdatesPage.php @@ -0,0 +1,55 @@ +
+

Обновление цен у товаров

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + + +
+ +
+ + + Обновляются только те товары, у которых присутствует артикул и цена не совпадает с базовой или акционной. + + + + + + + + + +
\ No newline at end of file