| @copyright 2007-2026 (c) AVE.cms | @link https://ave-cms.ru | @version 3.3 */ namespace App\Adminx\Documents; defined('BASEPATH') || die('Direct access to this location is not allowed.'); use App\Common\AuditLog; use App\Common\DatabaseSchema; use App\Common\Lock; use App\Content\CatalogTables; use App\Content\ContentTables; use App\Content\Documents\DocumentMutationService; use App\Content\Documents\DocumentSearch; use App\Helpers\File; use DB; /** * Server-side plans for safe, previewable document batch mutations. * * A plan freezes matching IDs before execution. The browser only sends the * opaque token back and cannot replace the document list after preview. */ class BulkEditor { const VERSION = 1; const MAX_DOCUMENTS = 5000; const CHUNK_SIZE = 20; const EXPIRES_AFTER = 7200; protected static $documentFields = array( 'document_title' => array( 'label' => 'Заголовок документа', 'payload' => 'title', 'help' => 'Основной заголовок документа в AVE.cms. У товара название на сайте может браться из отдельного поля рубрики.', ), 'document_excerpt' => array('label' => 'Тизер (анонс)', 'payload' => 'excerpt'), 'document_tags' => array('label' => 'Теги', 'payload' => 'tags'), 'document_meta_keywords' => array('label' => 'Meta keywords', 'payload' => 'meta_keywords'), 'document_meta_description' => array('label' => 'Meta description', 'payload' => 'meta_description'), 'document_property' => array('label' => 'Свойство / артикул', 'payload' => 'property'), ); public static function options() { return array( 'rubrics' => Model::rubrics(), 'document_fields' => self::$documentFields, 'products_available' => self::productsAvailable(), ); } public static function preview(array $input, $actorId) { $filters = self::filters($input); $operation = self::operation($input, $filters); $matches = self::matches($filters); if (!$matches['ids']) { throw new \InvalidArgumentException('По выбранным условиям документы не найдены'); } if ($matches['total'] > self::MAX_DOCUMENTS) { throw new \InvalidArgumentException( 'Найдено ' . $matches['total'] . ' документов. Уточните фильтр: за один запуск можно обработать до ' . self::MAX_DOCUMENTS ); } $matchedTotal = (int) $matches['total']; $changingRows = self::changingRows($matches['rows'], $operation); if (!$changingRows) { throw new \InvalidArgumentException('В найденных документах нет значений, которые изменит выбранное действие'); } $sample = array(); foreach (array_slice($changingRows, 0, 20) as $row) { $change = self::previewChange($row, $operation); $sample[] = array( 'id' => (int) $row['Id'], 'title' => html_entity_decode((string) $row['document_title'], ENT_QUOTES, 'UTF-8'), 'rubric' => html_entity_decode((string) $row['rubric_title'], ENT_QUOTES, 'UTF-8'), 'state' => (int) $row['document_status'] === 1 ? 'Опубликован' : 'Черновик', 'before' => self::shortValue($change['before']), 'after' => self::shortValue($change['after']), 'changed' => $change['changed'], 'note' => $change['note'], ); } $token = bin2hex(random_bytes(20)); $plan = array( 'version' => self::VERSION, 'token' => $token, 'actor_id' => (int) $actorId, 'status' => 'prepared', 'created_at' => time(), 'expires_at' => time() + self::EXPIRES_AFTER, 'filters' => $filters, 'operation' => $operation, 'ids' => array_values(array_map(function ($row) { return (int) $row['Id']; }, $changingRows)), 'total' => count($changingRows), 'matched_total' => $matchedTotal, 'unchanged_total' => max(0, $matchedTotal - count($changingRows)), 'cursor' => 0, 'done' => 0, 'skipped' => 0, 'errors' => array(), 'sample' => $sample, ); self::save($plan); return self::publicPlan($plan); } public static function runChunk($token, $actorId) { return Lock::run('document-bulk:' . (string) $token, function () use ($token, $actorId) { $plan = self::load($token, $actorId); if (in_array($plan['status'], array('completed', 'cancelled'), true)) { return self::publicPlan($plan); } $plan['status'] = 'running'; $service = new DocumentMutationService(); $end = min((int) $plan['total'], (int) $plan['cursor'] + self::CHUNK_SIZE); while ((int) $plan['cursor'] < $end) { $id = (int) $plan['ids'][$plan['cursor']]; try { $result = self::apply($service, $id, $plan['operation'], (int) $actorId); if ($result) { $plan['done']++; } else { $plan['skipped']++; } } catch (\Throwable $e) { $plan['errors'][] = '#' . $id . ': ' . $e->getMessage(); if (count($plan['errors']) > 100) { $plan['errors'] = array_slice($plan['errors'], -100); } } $plan['cursor']++; } if ((int) $plan['cursor'] >= (int) $plan['total']) { $plan['status'] = 'completed'; $plan['completed_at'] = time(); AuditLog::record('document.bulk_editor_completed', array( 'actor_id' => (int) $actorId, 'target_type' => 'document_batch', 'meta' => array( 'operation' => $plan['operation'], 'filters' => $plan['filters'], 'total' => $plan['total'], 'done' => $plan['done'], 'skipped' => $plan['skipped'], 'errors' => count($plan['errors']), ), )); } self::save($plan); return self::publicPlan($plan); }, 5.0, true); } public static function cancel($token, $actorId) { return Lock::run('document-bulk:' . (string) $token, function () use ($token, $actorId) { $plan = self::load($token, $actorId); if ($plan['status'] !== 'completed') { $plan['status'] = 'cancelled'; $plan['cancelled_at'] = time(); self::save($plan); } return self::publicPlan($plan); }, 5.0, true); } public static function status($token, $actorId) { return self::publicPlan(self::load($token, $actorId)); } protected static function apply(DocumentMutationService $service, $documentId, array $operation, $actorId) { $row = DB::query( "SELECT * FROM " . ContentTables::table('documents') . " WHERE Id=%i AND document_deleted='0' LIMIT 1", (int) $documentId )->getAssoc(); if (!$row) { return false; } $type = $operation['type']; if ($type === 'publish' || $type === 'unpublish') { if (Model::isProtectedDocument($documentId)) { return false; } $status = $type === 'publish' ? 1 : 0; if ((int) $row['document_status'] === $status) { return false; } $service->save($documentId, array('status' => $status), $actorId, 'bulk_editor'); return true; } if ($type === 'recalculate') { $service->save($documentId, array('fields' => array()), $actorId, 'bulk_editor'); return true; } if ($type === 'move') { if (Model::isProtectedDocument($documentId)) { return false; } $result = $service->move($documentId, $operation['target_rubric_id'], $actorId, 'bulk_editor'); return !empty($result['moved']); } $current = self::currentValue($row, $operation); $next = self::changedValue($current, $operation); if (!$next['changed']) { return false; } if ($operation['target_type'] === 'document') { $payloadKey = self::$documentFields[$operation['target']]['payload']; $service->save($documentId, array($payloadKey => $next['value']), $actorId, 'bulk_editor'); } else { $service->save($documentId, array('fields' => array($operation['field_id'] => $next['value'])), $actorId, 'bulk_editor'); } return true; } protected static function filters(array $input) { $scope = isset($input['scope']) ? (string) $input['scope'] : 'all'; if (!in_array($scope, array('all', 'documents', 'products'), true) || ($scope !== 'all' && !self::productsAvailable())) { $scope = 'all'; } $state = isset($input['state']) ? (string) $input['state'] : ''; if (!in_array($state, array('', 'active', 'draft'), true)) { $state = ''; } return array( 'q' => trim(isset($input['q']) ? (string) $input['q'] : ''), 'rubric_id' => max(0, isset($input['rubric_id']) ? (int) $input['rubric_id'] : 0), 'state' => $state, 'scope' => $scope, ); } protected static function operation(array $input, array $filters) { $type = isset($input['operation']) ? (string) $input['operation'] : ''; $allowed = array('fill', 'set', 'clear', 'replace', 'move', 'publish', 'unpublish', 'recalculate'); if (!in_array($type, $allowed, true)) { throw new \InvalidArgumentException('Выберите действие'); } $operation = array('type' => $type, 'label' => self::operationLabel($type)); if (in_array($type, array('move', 'publish', 'unpublish', 'recalculate'), true)) { if ($type === 'move') { $targetRubricId = isset($input['target_rubric_id']) ? (int) $input['target_rubric_id'] : 0; if ($targetRubricId <= 0 || !Model::rubric($targetRubricId)) { throw new \InvalidArgumentException('Выберите целевую рубрику'); } if ($filters['rubric_id'] > 0 && $targetRubricId === $filters['rubric_id']) { throw new \InvalidArgumentException('Исходная и целевая рубрики совпадают'); } $operation['target_rubric_id'] = $targetRubricId; $operation['target_rubric_title'] = Model::rubric($targetRubricId)['rubric_title']; } return $operation; } $target = isset($input['target']) ? trim((string) $input['target']) : ''; if (isset(self::$documentFields[$target])) { $operation['target_type'] = 'document'; $operation['target'] = $target; $operation['target_label'] = self::$documentFields[$target]['label']; } elseif (strpos($target, 'field:') === 0) { $fieldId = (int) substr($target, 6); if ($filters['rubric_id'] <= 0) { throw new \InvalidArgumentException('Для изменения поля сначала выберите одну рубрику'); } $field = self::field($fieldId, $filters['rubric_id']); if (!$field) { throw new \InvalidArgumentException('Поле не принадлежит выбранной рубрике'); } $operation['target_type'] = 'field'; $operation['target'] = $target; $operation['field_id'] = $fieldId; $operation['target_label'] = html_entity_decode((string) $field['rubric_field_title'], ENT_QUOTES, 'UTF-8'); } else { throw new \InvalidArgumentException('Выберите поле для изменения'); } $operation['value'] = isset($input['value']) ? (string) $input['value'] : ''; $operation['search'] = isset($input['search']) ? (string) $input['search'] : ''; if ($type === 'replace' && $operation['search'] === '') { throw new \InvalidArgumentException('Укажите текст, который нужно заменить'); } return $operation; } protected static function matches(array $filters) { $where = " WHERE d.document_deleted='0'"; $args = array(); if ($filters['rubric_id'] > 0) { $where .= ' AND d.rubric_id=%i'; $args[] = $filters['rubric_id']; } if ($filters['state'] === 'active') { $where .= " AND d.document_status='1'"; } elseif ($filters['state'] === 'draft') { $where .= " AND d.document_status='0'"; } $productTable = CatalogTables::table('catalog_product_index'); if (self::productsAvailable() && $filters['scope'] === 'products') { $where .= ' AND EXISTS (SELECT 1 FROM ' . $productTable . ' bp WHERE bp.product_id=d.Id)'; } elseif (self::productsAvailable() && $filters['scope'] === 'documents') { $where .= ' AND NOT EXISTS (SELECT 1 FROM ' . $productTable . ' bp WHERE bp.product_id=d.Id)'; } $search = DocumentSearch::criteria($filters['q'], 'd'); if ($search['where'] !== '') { $where .= ' AND ' . $search['where']; $args = array_merge($args, $search['where_args']); } $sql = 'SELECT d.Id,d.document_title,d.document_status,d.rubric_id,r.rubric_title' . ' FROM ' . ContentTables::table('documents') . ' d' . ' LEFT JOIN ' . ContentTables::table('rubrics') . ' r ON r.Id=d.rubric_id' . $where . ' ORDER BY d.Id ASC LIMIT ' . (self::MAX_DOCUMENTS + 1); $rows = call_user_func_array(array('DB', 'query'), array_merge(array($sql), $args))->getAll() ?: array(); $ids = array(); foreach ($rows as $row) { $ids[] = (int) $row['Id']; } return array('ids' => $ids, 'rows' => $rows, 'total' => count($rows)); } protected static function changingRows(array $rows, array $operation) { if (!$rows || $operation['type'] === 'recalculate') { return $rows; } $currentValues = array(); $ids = array(); foreach ($rows as $row) { $ids[] = (int) $row['Id']; } if (isset($operation['target_type']) && $operation['target_type'] === 'document') { $column = (string) $operation['target']; if (!isset(self::$documentFields[$column])) { throw new \InvalidArgumentException('Поле документа недоступно для массового изменения'); } $valueRows = DB::query( 'SELECT Id,`' . $column . '` current_value FROM ' . ContentTables::table('documents') . ' WHERE Id IN %li', $ids )->getAll(); foreach ($valueRows ?: array() as $valueRow) { $currentValues[(int) $valueRow['Id']] = html_entity_decode((string) $valueRow['current_value'], ENT_QUOTES, 'UTF-8'); } } elseif (isset($operation['target_type']) && $operation['target_type'] === 'field') { $valueRows = DB::query( 'SELECT df.document_id,CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\')) current_value' . ' FROM ' . ContentTables::table('document_fields') . ' df' . ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft' . ' ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id' . ' WHERE df.document_id IN %li AND df.rubric_field_id=%i', $ids, (int) $operation['field_id'] )->getAll(); foreach ($valueRows ?: array() as $valueRow) { $currentValues[(int) $valueRow['document_id']] = (string) $valueRow['current_value']; } } $changing = array(); foreach ($rows as $row) { if (isset($operation['target_type'])) { $row['__bulk_current'] = isset($currentValues[(int) $row['Id']]) ? $currentValues[(int) $row['Id']] : ''; } if (self::previewChange($row, $operation)['changed']) { $changing[] = $row; } } return $changing; } protected static function previewChange(array $row, array $operation) { if ($operation['type'] === 'publish') { return array('before' => (int) $row['document_status'] ? 'Опубликован' : 'Черновик', 'after' => 'Опубликован', 'changed' => !(int) $row['document_status'], 'note' => ''); } if ($operation['type'] === 'unpublish') { return array('before' => (int) $row['document_status'] ? 'Опубликован' : 'Черновик', 'after' => 'Черновик', 'changed' => (bool) $row['document_status'], 'note' => ''); } if ($operation['type'] === 'recalculate') { return array('before' => 'Текущие значения', 'after' => 'Пересчитать поля и индексы', 'changed' => true, 'note' => ''); } if ($operation['type'] === 'move') { return array('before' => (string) $row['rubric_title'], 'after' => (string) $operation['target_rubric_title'], 'changed' => (int) $row['rubric_id'] !== (int) $operation['target_rubric_id'], 'note' => 'Совпадающие поля переносятся по системному имени'); } $current = self::currentValue($row, $operation); $next = self::changedValue($current, $operation); return array('before' => $current, 'after' => $next['value'], 'changed' => $next['changed'], 'note' => $next['changed'] ? '' : 'Значение уже соответствует действию'); } protected static function currentValue(array $row, array $operation) { if (array_key_exists('__bulk_current', $row)) { return (string) $row['__bulk_current']; } if ($operation['target_type'] === 'document') { if (!array_key_exists($operation['target'], $row)) { $value = DB::query( 'SELECT `' . $operation['target'] . '` FROM ' . ContentTables::table('documents') . ' WHERE Id=%i', (int) $row['Id'] )->getValue(); return html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8'); } return html_entity_decode((string) $row[$operation['target']], ENT_QUOTES, 'UTF-8'); } return (string) DB::query( 'SELECT CONCAT(COALESCE(df.field_value,\'\'),COALESCE(dft.field_value,\'\'))' . ' FROM ' . ContentTables::table('document_fields') . ' df' . ' LEFT JOIN ' . ContentTables::table('document_fields_text') . ' dft' . ' ON dft.document_id=df.document_id AND dft.rubric_field_id=df.rubric_field_id' . ' WHERE df.document_id=%i AND df.rubric_field_id=%i LIMIT 1', (int) $row['Id'], (int) $operation['field_id'] )->getValue(); } protected static function changedValue($current, array $operation) { $current = (string) $current; $type = $operation['type']; if ($type === 'fill') { $value = trim($current) === '' ? (string) $operation['value'] : $current; } elseif ($type === 'clear') { $value = ''; } elseif ($type === 'replace') { $value = str_replace((string) $operation['search'], (string) $operation['value'], $current); } else { $value = (string) $operation['value']; } return array('value' => $value, 'changed' => $value !== $current); } protected static function field($fieldId, $rubricId) { return DB::query( 'SELECT Id,rubric_id,rubric_field_title,rubric_field_alias,rubric_field_type' . ' FROM ' . ContentTables::table('rubric_fields') . ' WHERE Id=%i AND rubric_id=%i LIMIT 1', (int) $fieldId, (int) $rubricId )->getAssoc() ?: null; } public static function fields($rubricId) { $commerce = self::commerceFieldUsage((int) $rubricId); $rows = DB::query( 'SELECT Id,rubric_field_title,rubric_field_alias,rubric_field_type' . ' FROM ' . ContentTables::table('rubric_fields') . ' WHERE rubric_id=%i ORDER BY rubric_field_position ASC,Id ASC', (int) $rubricId )->getAll(); $out = array(); foreach ($rows ?: array() as $row) { $fieldId = (int) $row['Id']; $usage = isset($commerce[$fieldId]) ? $commerce[$fieldId] : ''; $out[] = array( 'id' => $fieldId, 'title' => html_entity_decode((string) $row['rubric_field_title'], ENT_QUOTES, 'UTF-8'), 'alias' => (string) $row['rubric_field_alias'], 'type' => (string) $row['rubric_field_type'], 'usage' => $usage, 'help' => $usage !== '' ? 'Это поле используется товарным представлением как ' . $usage . '. Изменение обновит витрину и товарный индекс.' : '', ); } return $out; } protected static function commerceFieldUsage($rubricId) { if ($rubricId <= 0 || !self::productsAvailable()) { return array(); } $row = DB::query( 'SELECT product_title_field_id,product_article_field_id,product_price_field_id,' . 'product_old_price_field_id,product_stock_field_id,product_images_field_id' . ' FROM ' . CatalogTables::table('module_catalog_settings') . " WHERE rubric_id=%i AND purpose='commerce' ORDER BY id ASC LIMIT 1", (int) $rubricId )->getAssoc(); if (!$row) { return array(); } $labels = array( 'product_title_field_id' => 'название товара на сайте', 'product_article_field_id' => 'артикул товара', 'product_price_field_id' => 'текущая цена', 'product_old_price_field_id' => 'старая цена', 'product_stock_field_id' => 'остаток / наличие', 'product_images_field_id' => 'изображения товара', ); $usage = array(); foreach ($labels as $column => $label) { $fieldId = isset($row[$column]) ? (int) $row[$column] : 0; if ($fieldId > 0) { $usage[$fieldId] = $label; } } return $usage; } protected static function productsAvailable() { try { return DatabaseSchema::tableExists(CatalogTables::table('catalog_product_index')); } catch (\Throwable $e) { return false; } } protected static function operationLabel($type) { $labels = array( 'fill' => 'Заполнить пустые', 'set' => 'Установить значение', 'clear' => 'Очистить', 'replace' => 'Найти и заменить', 'move' => 'Перенести в рубрику', 'publish' => 'Опубликовать', 'unpublish' => 'Снять с публикации', 'recalculate' => 'Пересчитать поля и индексы', ); return isset($labels[$type]) ? $labels[$type] : $type; } protected static function shortValue($value) { $value = trim(preg_replace('/\s+/u', ' ', strip_tags((string) $value))); if ($value === '') { return 'Пусто'; } return mb_strlen($value, 'UTF-8') > 180 ? mb_substr($value, 0, 177, 'UTF-8') . '...' : $value; } protected static function publicPlan(array $plan) { $total = max(1, (int) $plan['total']); return array( 'token' => $plan['token'], 'status' => $plan['status'], 'total' => (int) $plan['total'], 'matched_total' => isset($plan['matched_total']) ? (int) $plan['matched_total'] : (int) $plan['total'], 'unchanged_total' => isset($plan['unchanged_total']) ? (int) $plan['unchanged_total'] : 0, 'processed' => (int) $plan['cursor'], 'done' => (int) $plan['done'], 'skipped' => (int) $plan['skipped'], 'errors' => $plan['errors'], 'progress' => (int) floor(((int) $plan['cursor'] / $total) * 100), 'operation' => $plan['operation'], 'filters' => $plan['filters'], 'sample' => $plan['sample'], ); } protected static function load($token, $actorId) { if (!preg_match('/^[a-f0-9]{40}$/', (string) $token)) { throw new \InvalidArgumentException('Некорректный идентификатор плана'); } $path = self::path($token); $data = is_file($path) ? json_decode((string) File::getContent($path), true) : null; if (!is_array($data) || (int) $data['version'] !== self::VERSION || !hash_equals((string) $data['token'], (string) $token)) { throw new \RuntimeException('План массового изменения не найден'); } if ((int) $data['actor_id'] !== (int) $actorId) { throw new \RuntimeException('План создан другим пользователем'); } if ((int) $data['expires_at'] < time() && !in_array($data['status'], array('completed', 'cancelled'), true)) { throw new \RuntimeException('Срок действия предпросмотра истёк. Выполните проверку ещё раз'); } return $data; } protected static function save(array $plan) { $directory = dirname(self::path($plan['token'])); if (!is_dir($directory) && !@mkdir($directory, 0750, true) && !is_dir($directory)) { throw new \RuntimeException('Не удалось создать каталог заданий'); } $raw = json_encode($plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; if (!File::putAtomic(self::path($plan['token']), $raw, 0640)) { throw new \RuntimeException('Не удалось сохранить план массового изменения'); } } protected static function path($token) { return rtrim(BASEPATH, '/\\') . '/storage/jobs/document-bulk/' . (string) $token . '.json'; } }