'Подключение', 'catalog' => 'Каталог', 'prices' => 'Цены и остатки', 'orders' => 'Заказы', 'advanced' => 'Дополнительно', 'service' => 'Обслуживание', ]; private const NOTICES = [ 'clear' => 'Временные файлы и прогресс импорта очищены.', 'reset_maps' => 'Кеш свойств и типов цен сброшен — заполнится при следующем обмене.', 'force_full' => 'Принудительный полный обмен запланирован: он выполнится при следующем обмене каталога из 1С.', 'cancel_force' => 'Принудительный полный обмен отменён.', 'cleanup_images' => 'Очистка картинок запущена в фоне. Обновите страницу через несколько минут, чтобы увидеть результат.', 'cleanup_busy' => 'Очистка картинок уже идёт.', ]; /** Разрешённая разметка элементов формы — для экранирования при выводе (wp_kses). */ private const FORM_HTML = [ 'select' => ['name' => true, 'id' => true], 'option' => ['value' => true, 'selected' => true], 'input' => [ 'type' => true, 'name' => true, 'value' => true, 'id' => true, 'class' => true, 'list' => true, 'min' => true, 'max' => true, 'step' => true, 'autocomplete' => true, 'placeholder' => true, 'readonly' => true, 'checked' => true, 'aria-label' => true, ], 'textarea' => ['name' => true, 'rows' => true, 'class' => true], 'label' => ['class' => true], 'span' => ['class' => true, 'aria-hidden' => true], 'div' => ['class' => true], 'p' => ['class' => true], 'code' => [], 'strong' => [], 'em' => [], ]; /** @var array */ private array $s; public function __construct() { $this->s = Settings::all(); } public function render(): void { // Только выбор текста уведомления из белого списка NOTICES (действие уже выполнено и проверено nonce). $done = isset($_GET['gersync_done']) ? sanitize_key(wp_unslash($_GET['gersync_done'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>

GerSync — обмен с 1С v


%s

', esc_html(self::NOTICES[$done])); } $this->summary(); ?>
tabConnection(); ?>
tabCatalog(); ?>
tabPrices(); ?>
tabOrders(); ?>
tabAdvanced(); ?>
Сохраняются настройки всех вкладок сразу.
tabService(); ?>
supportBar(); ?>
banner('warning', 'Запланирован принудительный полный обмен — он выполнится при следующем обмене каталога. Отменить можно на вкладке «Обслуживание».'); } if ($missingAt) { $this->banner('info', 'После полной выгрузки в очереди обработка товаров, которых в ней не было' . ($missingAt > time() ? ' (через ' . human_time_diff(time(), (int) $missingAt) . ').' : '.')); } if ($cleanup['running']) { $this->banner('info', sprintf('Идёт очистка неиспользуемых картинок: удалено %d.', $cleanup['deleted'])); } } // --- Вкладки ------------------------------------------------------------------ private function tabConnection(): void { $this->card('Адрес для 1С', function (): void { ?>

Укажите этот адрес, логин и пароль в 1С: Администрирование → Обмен данными → Обмен с сайтами (в разных конфигурациях путь немного отличается).

card('Доступ', function (): void { $this->toggle('enabled', 'Обмен включён', 'Выключите, чтобы временно запретить любые запросы от 1С.'); $this->field('Логин', 'nameAttr('login') . ' value="' . esc_attr((string) $this->s['login']) . '">', 'Латиницей. Не связан с пользователями WordPress.'); $placeholder = $this->s['password_hash'] ? '•••••••• (задан — оставьте пустым, чтобы не менять)' : ''; $this->field('Пароль', 'nameAttr('password') . ' value="" placeholder="' . esc_attr($placeholder) . '">', 'Хранится только в виде хеша. Используйте длинный случайный пароль.'); $this->field('Разрешённые IP', '', 'Необязательно. Если заполнено — обмен принимается только с этих адресов (по одному в строке). ' . 'После 10 неверных паролей адрес блокируется на 15 минут.'); }); $this->card('1С пишет «Сервер не получил логин/пароль»', function (): void { ?>

На части хостингов (PHP в режиме CGI/FastCGI) заголовок авторизации не доходит до PHP. Для Apache добавьте в .htaccess в корне сайта перед правилами WordPress:

RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

Для nginx + PHP-FPM: fastcgi_param HTTP_AUTHORIZATION $http_authorization;

card('Новые товары', function (): void { $this->select('new_status', 'Статус новых товаров', [ 'publish' => 'Опубликован', 'draft' => 'Черновик', 'pending' => 'На утверждении', 'private' => 'Личный', ]); $this->toggle('match_by_sku', 'Связывать с товарами сайта по артикулу', 'Для первого обмена, если товары на сайте заведены вручную и у них нет Ид 1С. Иначе появятся дубли.'); $this->toggle('import_attributes', 'Загружать свойства 1С как атрибуты WooCommerce', 'Нужно для фильтров в каталоге.'); }); $this->card('Обновление существующих товаров', function (): void { echo '

Выключите то, что правите на сайте вручную, — 1С не будет это перезаписывать. ' . 'Новые товары заполняются полностью. Цены и остатки — на вкладке «Цены и остатки».

'; echo '
'; foreach (['update_name' => 'Название', 'update_description' => 'Описание', 'update_categories' => 'Категории', 'update_images' => 'Картинки', 'update_sku' => 'Артикул', 'update_weight' => 'Вес'] as $key => $label) { $this->toggle($key, $label); } echo '
'; $this->select('attributes_mode', 'Атрибуты', [ 'sync' => 'Синхронизировать с 1С', 'merge' => 'Только добавлять значения из 1С, ничего не удалять', 'none' => 'Не трогать', ], 'Атрибуты, добавленные вручную (которых нет среди свойств 1С), не удаляются ни в каком режиме. Видимость и порядок сохраняются.'); echo '

Для отдельного товара есть галочка «Не обновлять из 1С» в карточке товара, вкладка «Запасы».

'; }); $this->card('Удаление и полная выгрузка', function (): void { $this->select('deleted_action', 'Помеченные на удаление в 1С', [ 'draft' => 'Переводить в черновики', 'trash' => 'Отправлять в корзину', 'none' => 'Ничего не делать', ], 'Если пометку в 1С снимут — товар вернётся с прежним статусом.'); $this->select('missing_action', 'Нет в полной выгрузке', [ 'none' => 'Ничего не делать', 'draft' => 'Снимать с публикации (черновик)', 'outofstock' => 'Ставить «Нет в наличии»', 'trash' => 'Отправлять в корзину', ], 'Тип выгрузки (полная / только изменения) определяется автоматически по файлу из 1С. При выгрузке изменений этот пункт не применяется. ' . 'Касается только товаров из 1С; выполняется в фоне после обмена. Если пропало больше половины товаров — ничего не делается.'); }); $this->card('Скорость обмена', function (): void { $this->toggle('skip_unchanged', 'Пропускать товары, которые в 1С не менялись', 'Главное ускорение: при повторном обмене обрабатываются только изменённые товары. ' . 'Чтобы один раз обновить всё — «Принудительный полный обмен» на вкладке «Обслуживание».'); $this->select('image_sizes', 'Размеры картинок при загрузке', [ 'all' => 'Все размеры (как обычно в WordPress)', 'woo' => 'Только нужные WooCommerce — быстрее', ], 'Каждый размер — отдельный файл; темы и плагины добавляют свои. Вариант «только WooCommerce» ускоряет загрузку картинок в разы. ' . 'Если тема где-то использует свой размер, там будет показана ближайшая подходящая картинка.'); }); } private function tabPrices(): void { $types = array_filter(array_map('strval', (array) get_option(Catalog::PRICES_OPTION, []))); $this->card('Цены', function () use ($types): void { echo ''; foreach ($types as $type) { echo ''; } echo ''; $this->field('Тип цены', 'nameAttr('price_type') . ' value="' . esc_attr((string) $this->s['price_type']) . '">', 'Название или Ид типа цены из 1С. Пусто — первая цена в предложении.'); $this->field('Цена распродажи', 'nameAttr('sale_price_type') . ' value="' . esc_attr((string) $this->s['sale_price_type']) . '">', 'Необязательно. Если цена этого типа ниже основной — станет ценой со скидкой.'); if ($types) { echo '

Типы цен из 1С: ' . esc_html(implode(', ', $types)) . '

'; } $this->toggle('update_price', 'Обновлять цены у существующих товаров', 'Выключено — цена ставится только товару без цены.'); }); $this->card('Остатки', function (): void { $this->toggle('manage_stock', 'Управлять запасами (точное количество)', 'Выключено — только «в наличии / нет в наличии».'); $this->toggle('update_stock', 'Обновлять остатки у существующих товаров', 'Остаток — сумма по всем складам 1С.'); }); } private function tabOrders(): void { $this->card('Выгрузка заказов в 1С', function (): void { $this->toggle('orders_enabled', 'Выгружать заказы в 1С', 'Заказ уходит после создания и после каждой смены статуса.'); $boxes = ''; foreach (wc_get_order_statuses() as $key => $label) { $status = substr($key, 3); $boxes .= sprintf( '', esc_attr(Settings::OPTION), esc_attr($status), checked(in_array($status, (array) $this->s['order_statuses'], true), true, false), esc_html($label) ); } $this->field('Статусы для выгрузки', '
' . $boxes . '
'); $this->field('Валюта в документе', 'nameAttr('order_currency') . ' value="' . esc_attr((string) $this->s['order_currency']) . '">', 'Пусто — код валюты заказа (RUB, EUR…). Некоторые конфигурации 1С ждут «руб».'); }); $this->card('Статусы из 1С', function (): void { $this->toggle('order_import_statuses', 'Принимать статусы заказов из 1С', 'Отменён → «Отменён», отгружен → «Выполнен», оплачен → «Обработка». Только для заказов, которые сайт сам отправлял в 1С.'); }); } private function tabAdvanced(): void { $this->card('Передача файлов', function (): void { $this->toggle('use_zip', 'Принимать файлы в zip-архиве', 'Быстрее передача. Нужно расширение PHP zip.'); $this->field('Длительность шага, сек', 'nameAttr('time_limit') . ' value="' . esc_attr((string) $this->s['time_limit']) . '">', 'Импорт идёт порциями, после каждой 1С повторяет запрос. Больше — меньше пауз между шагами; ' . 'уменьшите, если хостинг обрывает долгие запросы.'); $this->field('Размер части файла, МБ', 'nameAttr('file_limit_mb') . ' value="' . esc_attr((string) $this->s['file_limit_mb']) . '">', 'Сейчас фактически: ' . size_format(Storage::fileLimit()) . ' (ограничено post_max_size на сервере).'); $this->select('response_charset', 'Кодировка ответов для 1С', [ 'cp1251' => 'Windows-1251 (обычно для 1С)', 'utf-8' => 'UTF-8', ], 'Если в журнале обмена 1С вместо русских сообщений «РћР±СЂ…» — нужна Windows-1251.'); }); $this->card('Журнал', function (): void { $this->toggle('debug', 'Подробный журнал обмена', 'Каждый шаг пишется в журнал. Ошибки пишутся всегда.'); printf('

Журнал: WooCommerce → Статус → Журналы, источник «gersync».

', esc_url(admin_url('admin.php?page=wc-status&tab=logs'))); }); } private function tabService(): void { $this->card('Принудительный полный обмен', function (): void { ?>

Обычно плагин пропускает товары, которые в 1С не менялись. Принудительный обмен один раз обработает все товары заново — содержание, категории, атрибуты и картинки, как при первом обмене.

Что учесть:

Запланирован на следующий обмен

'; $this->toolButton('cancel_force', 'Отменить', 'secondary'); } else { $this->toolButton('force_full', 'Запланировать принудительный полный обмен', 'primary', 'Следующий обмен каталога обработает все товары заново и займёт заметно больше времени. Продолжить?'); } }); $this->card('Очистка картинок', function (): void { $status = MediaCleanup::status(); $count = $status['running'] ? 0 : MediaCleanup::count(); ?>

Удаляет из медиатеки картинки, загруженные обменом (этим или прежним плагином), которые больше не стоят ни у одного товара или вариации. Например, дубли после перехода со старого плагина.

Идёт очистка: удалено %d

', (int) $status['deleted']); } else { if ($status['finished']) { printf('

Последняя очистка: %s, удалено %d.

', esc_html(self::date($status['finished'])), (int) $status['deleted']); } printf('

Сейчас можно удалить: %s

', esc_html(number_format_i18n($count))); if ($count > 0) { $this->toolButton('cleanup_images', 'Удалить неиспользуемые картинки', 'secondary', sprintf('Будет удалено %d картинок из медиатеки без возможности восстановления. Продолжить?', $count)); } } }); $this->card('Временные файлы', function (): void { $stats = Storage::stats(); printf('

Файлы обмена: %d шт., %s. Удаляются автоматически в начале каждого обмена.

', (int) $stats['files'], esc_html(size_format($stats['bytes']) ?: '0 B')); echo '
'; $this->toolButton('clear', 'Очистить временные файлы и прогресс импорта', 'secondary'); $this->toolButton('reset_maps', 'Сбросить кеш свойств и типов цен', 'secondary'); echo '
'; }); } /** * Внизу каждой вкладки: «Плагин бесплатный», кофе автору (сумма на выбор) и ссылка на визитку. * Только в админке плагина, без всплывающих уведомлений — добровольно. */ private function supportBar(): void { if (GERSYNC_DONATE_URL === '' && GERSYNC_AUTHOR_URL === '') { return; } echo '
Плагин бесплатный — все функции работают без оплаты.'; if (GERSYNC_DONATE_URL !== '') { $withAmount = str_contains(GERSYNC_DONATE_URL, '{amount}') || str_contains(GERSYNC_DONATE_URL, '{cents}'); if ($withAmount) { printf( '☕ Угостить автора кофе:' . ' €' . ' Угостить', esc_attr(GERSYNC_DONATE_URL), esc_attr(GERSYNC_DONATE_DEFAULT), esc_url(Admin::donateUrl(GERSYNC_DONATE_DEFAULT)) ); } else { printf('☕ Угостить автора кофе', esc_url(GERSYNC_DONATE_URL)); } } if (GERSYNC_AUTHOR_URL !== '') { printf('Об авторе и других проектах →', esc_url(GERSYNC_AUTHOR_URL)); } echo '
'; } // --- Элементы ------------------------------------------------------------------- private function card(string $title, callable $body, bool $collapsed = false): void { if ($collapsed) { echo '
' . esc_html($title) . '
'; $body(); echo '
'; return; } echo '

' . esc_html($title) . '

'; $body(); echo '
'; } /** * Поле «подпись слева — элемент справа». $control уже экранирован вызывающим. */ private function field(string $label, string $control, string $hint = ''): void { echo '
' . esc_html($label) . '
'; echo wp_kses($control, self::FORM_HTML); if ($hint !== '') { echo '

' . esc_html($hint) . '

'; } echo '
'; } private function toggle(string $key, string $label, string $hint = ''): void { $html = sprintf( '
%s
', $this->nameAttr($key), checked(!empty($this->s[$key]), true, false), esc_html($label), $hint !== '' ? '

' . esc_html($hint) . '

' : '' ); echo wp_kses($html, self::FORM_HTML); } /** * @param array $options */ private function select(string $key, string $label, array $options, string $hint = ''): void { $html = '', $hint); } private function nameAttr(string $key): string { return 'name="' . esc_attr(Settings::OPTION . '[' . $key . ']') . '"'; } private function toolButton(string $tool, string $label, string $type, string $confirm = ''): void { ?>
data-confirm="">

%s

', esc_attr($type), esc_html($text)); } private static function date(int $timestamp): string { return wp_date(get_option('date_format') . ' H:i', $timestamp); } }