( 'log' === $tab && $auto ) ? 10000 : 0 ) ); } public function action_links( $links ) { array_unshift( $links, 'Настройки' ); return $links; } public function register_settings() { register_setting( 'rushop_modules', RuShop::MODULES_OPTION, array( 'sanitize_callback' => array( $this, 'sanitize_modules' ) ) ); if ( class_exists( 'RuShop_OneC' ) ) { register_setting( 'rushop_onec', RuShop_OneC::OPTION, array( 'sanitize_callback' => array( $this, 'sanitize_onec' ) ) ); } } /* ---------- Сохранение ---------- */ public function sanitize_modules( $value ) { $available = array(); foreach ( RuShop::modules() as $slug => $module ) { if ( ! empty( $module['available'] ) ) { $available[] = $slug; } } return array_values( array_intersect( $available, array_map( 'sanitize_key', (array) $value ) ) ); } public function sanitize_onec( $in ) { $in = (array) $in; $old = RuShop_OneC::settings(); $statuses = array_keys( wc_get_order_statuses() ); $flag = function ( $key ) use ( $in ) { return empty( $in[ $key ] ) ? 0 : 1; }; $meta_key = function ( $key ) use ( $in ) { return (string) preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) ( $in[ $key ] ?? '' ) ); }; // Пустое поле пароля — оставляем прежний. if ( ! empty( $in['password'] ) ) { $hash = wp_hash_password( (string) $in['password'] ); } else { $hash = $in['password_hash'] ?? $old['password_hash']; } $events = array(); foreach ( array( 'cancelled', 'paid', 'shipped' ) as $event ) { $value = (string) ( $in['status_events'][ $event ] ?? '' ); $events[ $event ] = in_array( $value, $statuses, true ) ? $value : ''; } $map = array(); foreach ( (array) ( $in['status_map'] ?? array() ) as $row ) { $from = sanitize_text_field( $row['from'] ?? '' ); $to = (string) ( $row['to'] ?? '' ); if ( '' !== $from && in_array( $to, $statuses, true ) ) { $map[] = array( 'from' => $from, 'to' => $to ); } } $warehouses = array_map( 'strval', array_keys( (array) get_option( RuShop_OneC_Catalog::WAREHOUSES, array() ) ) ); return array( 'login' => sanitize_text_field( $in['login'] ?? '' ), 'password_hash' => (string) $hash, 'new_status' => 'draft' === ( $in['new_status'] ?? '' ) ? 'draft' : 'publish', 'update_name' => $flag( 'update_name' ), 'update_description' => $flag( 'update_description' ), 'import_images' => $flag( 'import_images' ), 'import_properties' => $flag( 'import_properties' ), 'name_from_full' => $flag( 'name_from_full' ), 'missing_action' => in_array( $in['missing_action'] ?? '', array( 'none', 'draft', 'outofstock', 'trash' ), true ) ? $in['missing_action'] : 'draft', 'batch_size' => min( 1000, max( 1, absint( $in['batch_size'] ?? 50 ) ) ), 'price_type' => sanitize_text_field( $in['price_type'] ?? '' ), 'old_price_type' => sanitize_text_field( $in['old_price_type'] ?? '' ), 'warehouses' => array_values( array_intersect( $warehouses, array_map( 'strval', (array) ( $in['warehouses'] ?? array() ) ) ) ), 'export_statuses' => array_values( array_intersect( $statuses, (array) ( $in['export_statuses'] ?? array() ) ) ), 'export_limit' => min( 1000, max( 1, absint( $in['export_limit'] ?? 200 ) ) ), 'orders_schema' => in_array( $in['orders_schema'] ?? '', array( 'auto', '2.08', '3.1' ), true ) ? $in['orders_schema'] : 'auto', 'sale_init_version' => in_array( $in['sale_init_version'] ?? '', array( '2.05', '2.08', '2.09', '2.10', '3.1', 'request' ), true ) ? $in['sale_init_version'] : '2.10', 'inn_meta' => $meta_key( 'inn_meta' ), 'kpp_meta' => $meta_key( 'kpp_meta' ), 'update_order_items' => $flag( 'update_order_items' ), 'status_events' => $events, 'status_map' => $map, 'record_payments' => $flag( 'record_payments' ), 'log_mode' => in_array( $in['log_mode'] ?? '', array( 'basic', 'products', 'debug' ), true ) ? $in['log_mode'] : 'products', 'log_days' => min( 90, max( 1, absint( $in['log_days'] ?? 14 ) ) ), ); } /* ---------- Страница ---------- */ public function render() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( 'Нет доступа.' ); } $tabs = array( 'modules' => 'Модули' ); if ( class_exists( 'RuShop_OneC' ) ) { $tabs['onec'] = 'Обмен с 1С'; $tabs['log'] = 'Журнал обмена'; } $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'modules'; // phpcs:ignore if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'modules'; } echo '

RuShop для WooCommerce

'; settings_errors(); if ( 'onec' === $tab ) { $this->render_onec(); } elseif ( 'log' === $tab ) { $this->render_log(); } else { $this->render_modules(); } echo '
'; } private function render_modules() { $enabled = RuShop::enabled_modules(); echo '
'; settings_fields( 'rushop_modules' ); echo ''; foreach ( RuShop::modules() as $slug => $m ) { $available = ! empty( $m['available'] ); printf( '', esc_html( $m['title'] ), esc_attr( RuShop::MODULES_OPTION ), esc_attr( $slug ), checked( $available && in_array( $slug, $enabled, true ), true, false ), disabled( $available, false, false ), esc_html( $m['description'] . ( $available ? '' : ' Скоро.' ) ) ); } echo ''; submit_button( 'Сохранить' ); echo '
'; } private function render_onec() { $s = RuShop_OneC::settings(); $last = get_option( 'rushop_1c_last_exchange' ); $types = (array) get_option( RuShop_OneC_Catalog::PRICE_TYPES, array() ); $warehouses = (array) get_option( RuShop_OneC_Catalog::WAREHOUSES, array() ); $statuses = wc_get_order_statuses(); ?>

Подключение в 1С

Адрес сайта для узла обмена:

Логин и пароль — те, что вы зададите ниже.

Последний обмен:

Открыть журнал обмена

Доступ

row( 'Логин для 1С', $this->text( $s, 'login', '', 'off' ) ); $this->row( 'Пароль для 1С', sprintf( '', $this->name( 'password' ) ), $s['password_hash'] ? 'Пароль задан. Оставьте поле пустым, чтобы его не менять.' : 'Пароль не задан — без него 1С не подключится.' ); ?>

Каталог

row( 'Новые товары', $this->select( $s, 'new_status', array( 'publish' => 'Сразу публиковать', 'draft' => 'Сохранять черновиком' ) ) ); $this->row( 'При каждом обмене', $this->checkbox( $s, 'update_name', 'Обновлять названия товаров и категорий' ) . '
' . $this->checkbox( $s, 'update_description', 'Обновлять описания товаров' ) . '
' . $this->checkbox( $s, 'import_images', 'Загружать картинки' ) . '
' . $this->checkbox( $s, 'import_properties', 'Загружать свойства товаров в атрибуты' ) . '
' . $this->checkbox( $s, 'name_from_full', 'Брать название из реквизита «Полное наименование»' ), 'Снимите галочки, если правите названия или описания на сайте — иначе 1С их перезапишет.' ); $this->row( 'Товары, которых нет в полной выгрузке', $this->select( $s, 'missing_action', array( 'draft' => 'Переводить в черновики', 'outofstock' => 'Оставлять, но с нулевым остатком', 'trash' => 'Переносить в корзину', 'none' => 'Ничего не делать', ) ), 'Срабатывает, только когда 1С выгружает весь каталог, а не только изменения. Вариации в любом случае выключаются. Если товар снова появится в 1С, он вернётся на витрину.' ); $this->row( 'Товаров за один шаг', sprintf( '', $this->name( 'batch_size' ), esc_attr( $s['batch_size'] ) ), 'Уменьшите, если хостинг обрывает обмен по таймауту.' ); ?>

Цены и остатки

row( 'Тип цены', $this->text( $s, 'price_type', 'Розничная' ), 'Цена продажи. Если пусто — первый тип из выгрузки. ' . $found ); $this->row( 'Тип старой цены', $this->text( $s, 'old_price_type', 'Розничная до скидки' ), 'Необязательно. Если она выше цены продажи, на сайте покажется зачёркнутой.' ); if ( $warehouses ) { $boxes = ''; foreach ( $warehouses as $id => $title ) { $boxes .= sprintf( '
', $this->name( 'warehouses' ), esc_attr( $id ), checked( in_array( (string) $id, array_map( 'strval', (array) $s['warehouses'] ), true ), true, false ), esc_html( $title ) ); } $this->row( 'Склады для остатка', $boxes, 'Если ничего не отмечено, остаток считается по всем складам.' ); } else { $this->row( 'Склады для остатка', 'Склады появятся здесь после первого обмена, если 1С передаёт остатки по складам.' ); } ?>

Заказы

$label ) { $boxes .= sprintf( '
', $this->name( 'export_statuses' ), esc_attr( $key ), checked( in_array( $key, (array) $s['export_statuses'], true ), true, false ), esc_html( $label ) ); } $this->row( 'Какие заказы отправлять в 1С', $boxes ); $this->row( 'Заказов за один обмен', sprintf( '', $this->name( 'export_limit' ), esc_attr( $s['export_limit'] ) ) ); $this->row( 'Формат выгрузки заказов', $this->select( $s, 'orders_schema', array( 'auto' => 'Как запросит 1С', '2.08' => 'CommerceML 2.08', '3.1' => 'Протокол 3.1 (документы в «Контейнер»)', ) ), 'Меняйте, только если 1С не видит заказы.' ); $this->row( 'Версия формата для 1С', $this->select( $s, 'sale_init_version', array( '2.10' => '2.10 (подходит 1С:Розница 3.0)', '2.09' => '2.09', '2.08' => '2.08', '2.05' => '2.05', '3.1' => '3.1', 'request' => 'Ту, что запросит 1С', ) ), 'Что сайт отвечает 1С при начале обмена заказами (строка version). Если 1С пишет «Не удалось получить версию XML. Загрузка с сайта невозможна» или не запрашивает заказы, попробуйте другое значение. На выгрузку каталога не влияет.' ); $this->row( 'Поля юрлица в заказе', 'ИНН: ' . $this->text( $s, 'inn_meta', '_billing_inn' ) . '

КПП: ' . $this->text( $s, 'kpp_meta', '_billing_kpp' ), 'Ключи полей, в которых оформление заказа хранит ИНН и КПП. Если в заказе есть ИНН, покупатель уйдёт в 1С как юрлицо.' ); $this->row( 'Изменения из 1С', $this->checkbox( $s, 'update_order_items', 'Обновлять состав заказа: товары, количество, цены, доставку' ) . '
' . $this->checkbox( $s, 'record_payments', 'Записывать в заказ оплаты, проведённые в 1С' ), 'Оплата попадает в заказ, только если в 1С есть проведённый документ с суммой больше нуля. Она появляется в заметках заказа и в блоке «Оплаты из 1С» на его странице.' ); $event_rows = array( 'cancelled' => 'Заказ отменён в 1С', 'paid' => 'Оплата отмечена в 1С', 'shipped' => 'Отгрузка отмечена в 1С', ); $html = ''; foreach ( $event_rows as $event => $label ) { $html .= sprintf( '

%s

', esc_html( $label ), $this->status_select( $this->name( 'status_events' ) . '[' . $event . ']', (string) ( $s['status_events'][ $event ] ?? '' ), $statuses ) ); } $this->row( 'Статус на сайте, когда…', $html ); $rows = array_merge( (array) $s['status_map'], array_fill( 0, 3, array( 'from' => '', 'to' => '' ) ) ); $html = ''; foreach ( array_values( $rows ) as $i => $row ) { $base = $this->name( 'status_map' ) . '[' . $i . ']'; $html .= sprintf( '', $base, esc_attr( $row['from'] ), $this->status_select( $base . '[to]', (string) $row['to'], $statuses ) ); } $html .= '
Значение «Статус заказа» в 1ССтатус на сайте
%s
'; $this->row( 'Соответствие статусов', $html, 'Например: «Выполнен» → «Выполнен». Пустые строки не сохраняются. Таблица важнее событий выше, кроме отмены.' ); ?>

Журнал

row( 'Что записывать', $this->select( $s, 'log_mode', array( 'basic' => 'Только запросы, файлы, итоги и ошибки', 'products' => 'Плюс каждый созданный или изменённый товар, цена, остаток и заказ', 'debug' => 'Всё, включая товары без изменений и XML документов', ) ), 'Режим «Всё» нужен, чтобы разобраться с проблемой. В XML заказов есть имена и телефоны покупателей, поэтому потом верните обычный режим.' ); $this->row( 'Хранить', sprintf( ' дн.', $this->name( 'log_days' ), esc_attr( $s['log_days'] ) ), 'Старые записи удаляются сами.' ); ?>
run_id : 'all'; if ( isset( $_GET['run'] ) ) { // phpcs:ignore $run = (string) preg_replace( '/[^a-z0-9]/i', '', wp_unslash( $_GET['run'] ) ); // phpcs:ignore $run = '' === $run ? 'all' : $run; } $level = isset( $_GET['level'] ) ? sanitize_key( wp_unslash( $_GET['level'] ) ) : ''; // phpcs:ignore if ( ! in_array( $level, array( '', 'problems', 'error', 'warning', 'info', 'debug' ), true ) ) { $level = ''; } $kind = isset( $_GET['kind'] ) ? sanitize_key( wp_unslash( $_GET['kind'] ) ) : ''; // phpcs:ignore if ( ! isset( RuShop_OneC_Log::kinds()[ $kind ] ) ) { $kind = ''; } return array( 'run' => $run, 'level' => $level, 'kind' => $kind, 's' => isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '', // phpcs:ignore 'dir' => ( isset( $_GET['dir'] ) && 'asc' === $_GET['dir'] ) ? 'asc' : 'desc', // phpcs:ignore ); } private function log_object_link( $row ) { $id = (int) $row->object_id; if ( ! $id ) { return ''; } $url = ''; $label = ''; if ( in_array( $row->object_type, array( 'product', 'variation' ), true ) ) { $post_id = 'variation' === $row->object_type ? (int) wp_get_post_parent_id( $id ) : $id; $url = $post_id ? get_edit_post_link( $post_id, '' ) : ''; $label = ( 'variation' === $row->object_type ? 'Вариация #' : 'Товар #' ) . $id; } elseif ( 'order' === $row->object_type ) { $order = wc_get_order( $id ); $url = $order ? $order->get_edit_order_url() : ''; $label = 'Заказ #' . $id; } elseif ( 'category' === $row->object_type ) { $url = get_edit_term_link( $id, 'product_cat' ); $label = 'Категория #' . $id; } if ( '' === $label ) { return ''; } return $url ? '' . esc_html( $label ) . '' : esc_html( $label ); } private function render_log() { $runs = RuShop_OneC_Log::runs( 30 ); $f = $this->log_filters( $runs ); $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore $per = 100; $auto = ! empty( $_GET['auto'] ); // phpcs:ignore $levels = RuShop_OneC_Log::level_labels(); $kinds = RuShop_OneC_Log::kinds(); list( $rows, $total ) = RuShop_OneC_Log::query( $f, $page, $per, $f['dir'] ); list( $summary ) = RuShop_OneC_Log::query( array( 'run' => $f['run'], 'kind' => 'summary' ), 1, 12, 'DESC' ); $args = array( 'page' => 'rushop', 'tab' => 'log', 'run' => $f['run'], 'level' => $f['level'], 'kind' => $f['kind'], 's' => $f['s'], 'dir' => $f['dir'], ); $base = add_query_arg( $args, admin_url( 'admin.php' ) ); // Итоги выбранного обмена. if ( $summary ) { echo '

Итоги

'; } // Фильтры. echo '
'; echo ''; if ( $auto ) { echo ''; } echo ' '; echo ' '; echo ' '; printf( ' ', esc_attr( $f['s'] ) ); echo ' '; submit_button( 'Показать', 'secondary', '', false ); echo '
'; $export = wp_nonce_url( add_query_arg( array_merge( $args, array( 'action' => 'rushop_1c_log_export' ) ), admin_url( 'admin-post.php' ) ), 'rushop_1c_log' ); printf( '

Записей: %d. %s · Скачать CSV

', (int) $total, esc_url( $auto ? remove_query_arg( 'auto', $base ) : add_query_arg( 'auto', 1, $base ) ), $auto ? 'Выключить автообновление' : 'Включить автообновление (каждые 10 с)', esc_url( $export ) ); // Таблица. if ( ! $rows ) { echo '

Записей пока нет. Они появятся, когда 1С начнёт обмен с сайтом.

'; } else { echo ''; foreach ( $rows as $row ) { $details = ''; if ( '' !== (string) $row->data ) { $decoded = json_decode( $row->data, true ); $pretty = is_array( $decoded ) ? wp_json_encode( $decoded, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT ) : $row->data; $details = '
подробности
' . esc_html( $pretty ) . '
'; } printf( '', esc_html( get_date_from_gmt( $row->logged_at, 'd.m H:i:s' ) ), esc_attr( $row->level ), esc_html( $levels[ $row->level ] ?? $row->level ), esc_html( $kinds[ $row->kind ] ?? $row->kind ), esc_html( $row->message ), '' !== (string) $row->ref && ! in_array( $row->kind, array( 'request', 'summary' ), true ) ? '
Ид 1С: ' . esc_html( $row->ref ) . '' : '', $details, // phpcs:ignore $this->log_object_link( $row ) // phpcs:ignore ); } echo '
ВремяУровеньСобытиеЧто произошлоОбъект
%s%s%s%s%s%s%s
'; $pages = (int) ceil( $total / $per ); if ( $pages > 1 ) { echo '
' . wp_kses_post( (string) paginate_links( array( 'base' => add_query_arg( 'paged', '%#%', $base ), 'format' => '', 'current' => $page, 'total' => $pages, ) ) ) . '
'; } } // Очистка. echo '
'; echo ''; wp_nonce_field( 'rushop_1c_log_clear' ); submit_button( 'Очистить журнал', 'delete', '', false ); echo '
'; } public function log_export() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( 'Нет доступа.' ); } check_admin_referer( 'rushop_1c_log' ); if ( ! class_exists( 'RuShop_OneC_Log' ) ) { wp_die( 'Модуль «Обмен с 1С» не включён.' ); } $f = $this->log_filters( array() ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="rushop-1c-log-' . gmdate( 'Ymd-His' ) . '.csv"' ); $out = fopen( 'php://output', 'w' ); // phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- потоковый вывод CSV в php://output, не файл на диске. fwrite( $out, "\xEF\xBB\xBF" ); // BOM, чтобы Excel не ломал кириллицу fputcsv( $out, array( 'Время', 'Обмен', 'Уровень', 'Событие', 'Тип объекта', 'ID объекта', 'Ид 1С', 'Сообщение', 'Данные' ), ';' ); $cell = function ( $value ) { $value = (string) $value; return preg_match( '/^[=+\-@\t\r]/', $value ) ? "'" . $value : $value; // защита от формул в Excel }; $offset = 0; do { $rows = RuShop_OneC_Log::rows( $f, 2000, $offset, 'ASC' ); foreach ( $rows as $r ) { fputcsv( $out, array( get_date_from_gmt( $r->logged_at, 'Y-m-d H:i:s' ), $r->run_id, $r->level, $r->kind, $r->object_type, $r->object_id, $cell( $r->ref ), $cell( $r->message ), $cell( $r->data ), ), ';' ); } $offset += 2000; } while ( 2000 === count( $rows ) && $offset < 200000 ); fclose( $out ); // phpcs:enable WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } public function log_clear() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( 'Нет доступа.' ); } check_admin_referer( 'rushop_1c_log_clear' ); if ( class_exists( 'RuShop_OneC_Log' ) ) { RuShop_OneC_Log::clear(); } wp_safe_redirect( admin_url( 'admin.php?page=rushop&tab=log' ) ); exit; } /* ---------- Поля формы ---------- */ private function name( $key ) { return esc_attr( RuShop_OneC::OPTION . '[' . $key . ']' ); } private function row( $label, $html, $description = '' ) { printf( '%s%s%s', esc_html( $label ), $html, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- собрано из экранированных частей '' !== $description ? '

' . $description . '

' : '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- подсказки заданы в коде ); } private function text( array $s, $key, $placeholder = '', $autocomplete = '' ) { return sprintf( '', $this->name( $key ), esc_attr( (string) $s[ $key ] ), esc_attr( $placeholder ), $autocomplete ? ' autocomplete="' . esc_attr( $autocomplete ) . '"' : '' ); } private function checkbox( array $s, $key, $label ) { return sprintf( '', $this->name( $key ), checked( ! empty( $s[ $key ] ), true, false ), esc_html( $label ) ); } private function select( array $s, $key, array $options ) { $html = sprintf( ''; } private function status_select( $name, $current, array $statuses ) { $html = sprintf( ''; } }