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

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

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>