Dmitriy | Инициализация.

This commit is contained in:
27 changed files with 1464 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
vendor
composer.lock

View File

@@ -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;
}

View File

@@ -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;
}
}

69
assets/js/options-page.js Normal file
View File

@@ -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");
}
})
});

View File

@@ -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(`Измененно элементов: <span>${count}</span>`);
}
function updatePriceUpdatesCountError(count) {
if (typeof count !== "number") return;
$count.removeClass("hidden");
$countError.removeClass("hidden");
$countError.html(`Неудалось изменить элементов: <span>${count}</span>`);
}
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");
}
})
})
});

24
composer.json Normal file
View File

@@ -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"
}
}

View File

@@ -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"
}

38
price-updates.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
/**
* Plugin Name: Price Updates
* Description: Update price for products
* Version: 1.0.0
* Requires PHP: 8.0
* Author: Good Projects
*
* @package price-updates
*/
defined( 'ABSPATH' ) || exit;
require __DIR__ . '/vendor/autoload.php';
define("PRICE_UPDATES_VERSION", "1.0.0");
define("PRICE_UPDATES_PLUGIN_DIR", __DIR__);
define("PRICE_UPDATES_PLUGIN_URL", plugin_dir_url(__FILE__));
use Cosmopet\PriceUpdates\RestApi;
use Cosmopet\PriceUpdates\Admin\Menu;
use Cosmopet\PriceUpdates\Parser\GoogleTableParser;
class PriceUpdates {
public function __construct() {
// Инициализация меню и страниц в админ панели
Menu::init();
// Регистрация rest api контроллеров
RestApi::register();
// Инициализируем парсер гугл-таблиц (клиента и сервиса для работы с ней)
GoogleTableParser::init();
}
}
new PriceUpdates();

17
src/Admin/Menu.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace Cosmopet\PriceUpdates\Admin;
class Menu {
public static function init(): void {
add_action("admin_menu", function () {
PriceUpdatesMenu::init();
OptionsSubMenu::init(PriceUpdatesMenu::getSlug());
});
add_action("admin_enqueue_scripts", function ($hook) {
PriceUpdatesMenu::enqueue($hook);
OptionsSubMenu::enqueue($hook);
});
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Cosmopet\PriceUpdates\Admin;
use Cosmopet\PriceUpdates\RestApi;
use Cosmopet\PriceUpdates\Controllers\OptionsController;
class OptionsSubMenu {
private static string $slug = "price-updates-options";
public static function init(string $parentSlug): void {
add_submenu_page(
$parentSlug,
"Настройки",
"Настройки",
"manage_options",
self::$slug,
fn () => 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(),
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Cosmopet\PriceUpdates\Admin;
use Cosmopet\PriceUpdates\RestApi;
class PriceUpdatesMenu {
private static string $slug = "price-updates";
public static function init(): void {
add_menu_page(
"Обновление цен",
"Обновление цен",
"manage_options",
self::$slug,
fn () => 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;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Cosmopet\PriceUpdates\Classes;
class ParseProduct {
/**
* @var string Артикул товара
*/
private string $sku;
/**
* @var int Цены товара (Базовая и Аукционная)
*/
private int $price = 0;
public function __construct(string $sku, int $price) {
$this->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(),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Cosmopet\PriceUpdates\Classes;
class ParseProductError {
/**
* @var string Артикул товара
*/
private string $sku;
/**
* @var string Сообщение об ошибке
*/
private string $message;
public function __construct(string $sku, string $message) {
$this->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(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Cosmopet\PriceUpdates\Controllers;
use Cosmopet\PriceUpdates\RestApi;
use Cosmopet\PriceUpdates\Controllers\Interface\RegisterControllerInterface;
class AbstractController implements RegisterControllerInterface {
protected static string $namespace = "";
protected static array $methods = [];
public static function register(): void {
foreach (static::$methods as $method) {
register_rest_route(RestApi::$uri . static::$namespace, $method["uri"], array_diff_key($method, ["uri"]));
}
}
public static function getNamespace(): string {
return static::$namespace;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Cosmopet\PriceUpdates\Controllers\Interface;
interface RegisterControllerInterface {
public static function register(): void;
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Cosmopet\PriceUpdates\Controllers;
use Cosmopet\PriceUpdates\Services\OptionsService;
use Cosmopet\PriceUpdates\Parser\GoogleTableParser;
class OptionsController extends AbstractController {
protected static string $namespace = "/options";
protected static array $methods = [
[
"uri" => "/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);
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Cosmopet\PriceUpdates\Controllers;
use Cosmopet\PriceUpdates\Services\WCPriceUpdate;
use Cosmopet\PriceUpdates\Parser\ExcelParser;
use Cosmopet\PriceUpdates\Parser\GoogleTableParser;
class PriceUpdatesController extends AbstractController {
private static array $allowedExtensions = ["xls", "xlsx"];
protected static array $methods = [
[
"uri" => "/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");
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Cosmopet\PriceUpdates\Parser;
use Cosmopet\PriceUpdates\Parser\Interface\ParserInterface;
abstract class AbstractParser implements ParserInterface {
/**
* @param $data Файл
*
* @return \Cosmopet\PriceUpdates\Classes\ParseProduct[]
*/
public static function parse($data): array {
return self::$data;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Cosmopet\PriceUpdates\Parser;
use Shuchkin\SimpleXLSX;
use Cosmopet\PriceUpdates\Classes\ParseProduct;
class ExcelParser extends AbstractParser {
public static function parse($data): array {
$file = wp_handle_upload($data, [ "test_form" => 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;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace Cosmopet\PriceUpdates\Parser;
use Cosmopet\PriceUpdates\Classes\ParseProduct;
use Cosmopet\PriceUpdates\Classes\ParseProductError;
use Cosmopet\PriceUpdates\Services\OptionsService;
use Cosmopet\PriceUpdates\Utils\GoogleAuthConfigValidator;
class GoogleTableParser extends AbstractParser {
private static \Google_Service_Sheets $service;
public static function init() {
$client = new \Google_Client();
$client->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;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Cosmopet\PriceUpdates\Parser\Interface;
interface ParserInterface {
public static function parse($data): array;
}

26
src/RestApi.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace Cosmopet\PriceUpdates;
class RestApi {
public static string $uri = "price-updates/api/v1";
private static array $controllers = [
"\Cosmopet\PriceUpdates\Controllers\PriceUpdatesController",
"\Cosmopet\PriceUpdates\Controllers\OptionsController",
];
public static function register(): void {
add_action("rest_api_init", function() {
foreach(self::$controllers as $controller) {
if (method_exists($controller, "register")) {
$controller::register();
}
}
});
}
public static function getAjaxUrl(): string {
return get_rest_url(null, self::$uri);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Cosmopet\PriceUpdates\Services;
use Cosmopet\PriceUpdates\Utils\GoogleAuthConfigValidator;
class OptionsService {
public static string $filePath = PRICE_UPDATES_PLUGIN_DIR . "/config/google/credentials.json";
public static function get() {
$file = file_get_contents(self::$filePath);
if ($file === false) {
return [
"error" => "Файл не найден"
];
}
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);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Cosmopet\PriceUpdates\Services;
use Cosmopet\PriceUpdates\Classes\ParseProductError;
class WCPriceUpdate {
public static function update(array $data): array {
if (count($data) === 0) return [];
$result = [];
foreach($data as $item) {
if ($item instanceof ParseProductError) {
$result[] = array_merge($item->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;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Cosmopet\PriceUpdates\Utils;
class GoogleAuthConfigValidator {
private static array $fields = [
"type",
"project_id",
"private_key_id",
"private_key",
"client_email",
"client_id",
"auth_uri",
"token_uri",
"auth_provider_x509_cert_url",
"client_x509_cert_url",
"universe_domain"
];
public static function validate($data): bool {
if (gettype($data) !== "array" || count($data) !== count(self::$fields)) return false;
foreach (array_keys($data) as $key) {
if (!in_array($key, self::$fields)) {
return false;
}
}
return true;
}
}

57
src/Views/OptionsPage.php Normal file
View File

@@ -0,0 +1,57 @@
<div class="price-updates-options">
<h1>Настройки</h1>
<form class="price-updates-options__form">
<div class="price-updates-options__input-wrapper">
<label for="type">type</label>
<input type="text" id="type" name="type" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="project_id">project_id</label>
<input type="text" id="project_id" name="project_id" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="private_key_id">private_key_id</label>
<input type="text" id="private_key_id" name="private_key_id" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="private_key">private_key</label>
<input type="text" id="private_key" name="private_key" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="client_email">client_email</label>
<input type="text" id="client_email" name="client_email" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="client_id">client_id</label>
<input type="text" id="client_id" name="client_id" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="auth_uri">auth_uri</label>
<input type="text" id="auth_uri" name="auth_uri" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="token_uri">token_uri</label>
<input type="text" id="token_uri" name="token_uri" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="auth_provider_x509_cert_url">auth_provider_x509_cert_url</label>
<input type="text" id="auth_provider_x509_cert_url" name="auth_provider_x509_cert_url" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="client_x509_cert_url">client_x509_cert_url</label>
<input type="text" id="client_x509_cert_url" name="client_x509_cert_url" />
</div>
<div class="price-updates-options__input-wrapper">
<label for="universe_domain">universe_domain</label>
<input type="text" id="universe_domain" name="universe_domain" />
</div>
<div class="price-updates-options__submit-wrapper">
<button type="submit" class="price-updates-options__submit">Обновить</button>
<div class="price-updates-options__success-message hidden"></div>
<div class="price-updates-options__error-message hidden"></div>
</div>
</form>
<div class="price-updates-loader-wrapper hidden">
<div class="price-updates-loader"></div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
<div class="price-updates-wrapper">
<h1>Обновление цен у товаров</h1>
<div class="price-updates-variations">
<div>
<input type="radio" id="excel" name="price-updates-variant" value="excel" checked />
<label for="excel">Обновить через файл Excel</label>
</div>
<div>
<input type="radio" id="google-table" name="price-updates-variant" value="google-table" />
<label for="google-table">Обновить через google-таблицу</label>
</div>
</div>
<div class="price-updates-contents">
<form data-price-updates-variant="excel">
<input type="file" name="file" require />
<button type="submit">Обновить</button>
<input type="hidden" name="type" value="excel" accept=".xlsx,.xls" />
</form>
<form data-price-updates-variant="google-table" class="hidden">
<input type="url" name="url" placeholder="Ссылка на гугл таблицу" require />
<button type="submit">Обновить</button>
<input type="hidden" name="type" value="google-table" />
</form>
</div>
<span class="price-updates-caption">
Обновляются только те товары, у которых <b>присутствует артикул и цена не совпадает с базовой или акционной</b>.
</span>
<div class="price-updates-count hidden">
<span class="price-updates-count-success"></span>
<span class="price-updates-count-error"></span>
</div>
<div class="price-updates-response hidden">
<table>
<thead>
<tr>
<th>Артикул</th>
<th>Базовая цена</th>
<th>Акционная цена</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="price-updates-error hidden"></div>
<div class="price-updates-loader-wrapper hidden">
<div class="price-updates-loader"></div>
</div>
</div>