mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-06 02:35:45 +00:00
614 lines
24 KiB
PHP
614 lines
24 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file adminx/modules/Documents/BulkEditor.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @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';
|
|
}
|
|
}
|