Files
ave-cms/adminx/modules/Requests/Controller.php
T
2026-07-27 12:58:44 +03:00

683 lines
26 KiB
PHP

<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file adminx/modules/Requests/Controller.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\Requests;
defined('BASEPATH') || die('Direct access to this location is not allowed.');
use App\Common\AdminAssets;
use App\Common\Controller as BaseController;
use App\Common\Permission;
use App\Common\ErrorReport;
use App\Adminx\Support\CodeEditor;
use App\Content\Requests\RequestViewPreview;
use App\Content\Requests\RequestMatchExplainer;
use App\Content\Requests\RequestRendererRegistry;
use App\Content\Requests\RequestExecutorComparator;
use App\Helpers\Request;
/**
* Раздел «Запросы»: список + редактор запроса (поля, шаблоны item/main в
* CodeMirror, условия). Управление данными; теги/PHP не исполняются.
*/
class Controller extends BaseController
{
/** GET /requests — список. */
public function index(array $params = [])
{
AdminAssets::addStyle($this->base() . '/modules/Requests/assets/requests.css', 50);
AdminAssets::addScript($this->base() . '/modules/Requests/assets/requests.js', 50);
return $this->render('@requests/index.twig', [
'requests' => Model::all(),
'stats' => Model::stats(),
'can_manage' => Permission::check('manage_requests'),
]);
}
/** GET /requests/native-audit — массовая безопасная сверка executors. */
public function nativeAudit(array $params = array())
{
AdminAssets::addStyle($this->base() . '/modules/Requests/assets/requests.css', 50);
AdminAssets::addScript($this->base() . '/modules/Requests/assets/requests.js', 50);
$rows = Model::nativeAuditRows();
return $this->render('@requests/native-audit.twig', array(
'rows' => $rows,
'stats' => Model::nativeAuditStats($rows),
'can_manage' => Permission::check('manage_requests'),
));
}
/** POST /requests/native-audit/prepare — Shadow не меняет публичный результат. */
public function nativeAuditPrepare(array $params = array())
{
if (($resp = $this->guard()) !== null) { return $resp; }
$staged = Model::stageLegacyConditionValues();
Model::setAllExecutorMode('shadow');
$ids = array();
foreach (Model::nativeAuditRows() as $row) { $ids[] = (int) $row['id']; }
$message = 'Все запросы переведены в безопасный режим Shadow';
if (!empty($staged['conditions'])) {
$message .= '. Подготовлено декларативных условий: ' . (int) $staged['conditions'];
}
return $this->success($message, array('data' => array('ids' => $ids, 'staged' => $staged)));
}
/** POST /requests/{id}/native-audit — сверка и фиксация хеша плана. */
public function nativeAuditOne(array $params = array())
{
if (($resp = $this->guard()) !== null) { return $resp; }
$id = isset($params['id']) ? (int) $params['id'] : 0;
if (!Model::find($id)) { return $this->error('Запрос не найден', array(), 404); }
try {
$comparison = (new RequestExecutorComparator())->compare($id, 20);
if (Model::conditionValuesEquivalent($comparison)) {
$finalized = Model::finalizeStagedConditionValues(
$id,
isset($comparison['runtime_verification']) ? $comparison['runtime_verification'] : array()
);
if (is_array($finalized)) {
$comparison = $finalized;
$comparison['condition_tags_finalized'] = true;
}
}
Model::recordNativeVerification($id, $comparison);
} catch (\Throwable $e) {
return $this->error(ErrorReport::publicMessage(
'Теневое сравнение не выполнено',
$e,
'REQUEST_NATIVE_AUDIT',
array('request_id' => $id)
), array(), 422);
}
$message = !empty($comparison['condition_tags_finalized'])
? 'Запрос проверен, PHP-условия заменены декларативными тегами'
: 'Запрос проверен';
return $this->success($message, array('data' => array('comparison' => $comparison)));
}
/** POST /requests/native-audit/activate — только подтверждённые планы. */
public function nativeAuditActivate(array $params = array())
{
if (($resp = $this->guard()) !== null) { return $resp; }
$count = Model::activateVerifiedNative();
return $this->success('Native включён только для подтверждённых запросов', array('data' => array('count' => $count)));
}
/** POST /requests/native-audit/rollback — мгновенный общий откат. */
public function nativeAuditRollback(array $params = array())
{
if (($resp = $this->guard()) !== null) { return $resp; }
$count = Model::setAllExecutorMode('legacy');
return $this->success('Все запросы возвращены в Legacy', array('data' => array('count' => $count)));
}
/** POST /requests/{id}/native-stabilize — safe ID tiebreaker with rollback. */
public function nativeAuditStabilize(array $params = array())
{
if (($resp = $this->guard()) !== null) { return $resp; }
$id = isset($params['id']) ? (int) $params['id'] : 0;
if (!Model::find($id)) { return $this->error('Запрос не найден', array(), 404); }
try {
$result = Model::stabilizeNativeOrder($id);
} catch (\Throwable $e) {
return $this->error(ErrorReport::publicMessage(
'Не удалось безопасно закрепить порядок',
$e,
'REQUEST_NATIVE_STABILIZE',
array('request_id' => $id)
), array(), 422);
}
return $this->success((string) $result['message'], array('data' => $result));
}
/** GET /requests/new — форма создания. */
public function createForm(array $params = [])
{
return $this->editView(null);
}
/** GET /requests/{id} — форма редактирования. */
public function edit(array $params = [])
{
$request = Model::find($params['id'] ?? 0);
if (!$request) {
return $this->editNotFound();
}
return $this->editView($request);
}
protected function editView($request)
{
CodeEditor::useCodeMirror('application/x-httpd-php');
AdminAssets::addStyle($this->base() . '/modules/Requests/assets/requests.css', 50);
AdminAssets::addScript($this->base() . '/modules/Requests/assets/requests.js', 50);
$id = $request ? (int) $request->Id : 0;
$rubricFields = $id ? Model::rubricFields((int) $request->rubric_id) : array();
//-- Палитра тегов из общего реестра модуля Templates (если он есть).
$tagGroups = array();
if (class_exists('App\\Adminx\\Templates\\TagRegistry')) {
$tagGroups = \App\Adminx\Templates\TagRegistry::groups();
}
if ($rubricFields) {
array_unshift($tagGroups, $this->rubricFieldTagGroup($rubricFields));
}
$nativePlan = $id ? Model::nativePlan($id) : array(
'eligible' => false,
'reasons' => array('Сначала сохраните запрос.'),
);
$paginationOptions = Model::paginationOptions();
return $this->render('@requests/edit.twig', [
'request' => $request,
'condition_tree' => $id ? Model::conditionTree($id) : [],
'compares' => Model::compareOptions(),
'condition_value_modes' => Model::conditionValueModes(),
'rubrics' => Model::rubricOptions(),
'rubric_fields' => $rubricFields,
'order_options' => \App\Frontend\RequestSort::storedOptions(),
'sort_rules' => Model::sortRules($request),
'pagination_options' => $paginationOptions,
'pagination_ids' => array_map(function ($item) { return (int) $item['id']; }, $paginationOptions),
'result_contract' => Model::resultContract($request),
'result_system_options' => \App\Content\Requests\RequestResultContract::systemOptions(),
'preview_renderer' => Model::previewRenderer($request),
'preview_renderers' => RequestRendererRegistry::previewOptions(),
'executor_mode' => Model::executorMode($request),
'native_plan' => $nativePlan,
'native_verified' => $request ? Model::nativeVerified($request, $nativePlan) : false,
'flags' => Model::$flags,
'tag_groups' => $tagGroups,
'saved' => Request::getInt('saved') === 1,
'can_manage' => Permission::check('manage_requests'),
]);
}
/** Tags available only while editing one request result item. */
protected function rubricFieldTagGroup(array $fields)
{
$items = array();
foreach ($fields as $field) {
$id = isset($field->Id) ? (int) $field->Id : 0;
$alias = isset($field->rubric_field_alias) ? trim((string) $field->rubric_field_alias) : '';
$key = $alias !== '' ? $alias : (string) $id;
if ($key === '' || $key === '0') { continue; }
$title = isset($field->rubric_field_title) ? trim((string) $field->rubric_field_title) : '';
$type = isset($field->condition_type_label)
? trim((string) $field->condition_type_label)
: trim(isset($field->rubric_field_type) ? (string) $field->rubric_field_type : '');
$tag = '[tag:rfld:' . $key . '][0]';
$description = $title !== '' ? $title : ('Поле #' . $id);
if ($type !== '') { $description .= ' · ' . $type; }
$items[] = array(
'value' => $tag,
'label' => $tag,
'description' => $description,
'select' => '',
);
}
return array(
'title' => 'Поля рубрики',
'scope' => 'item',
'items' => $items,
);
}
protected function editNotFound()
{
\App\Helpers\Response::setStatus(404);
return $this->render('@adminx/404.twig', []);
}
/** POST /requests — создать. */
public function store(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$data = $this->input();
if ($errors = $this->validate($data, 0)) {
return $this->error('Проверьте поля', $errors);
}
$id = Model::create($data);
return $this->success('Запрос создан', ['redirect' => $this->base() . '/requests/' . $id . '?saved=1']);
}
/** POST /requests/{id} — сохранить. */
public function update(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$id = (int) ($params['id'] ?? 0);
$request = Model::find($id);
if (!$request) {
return $this->error('Запрос не найден', [], 404);
}
$data = $this->input();
if ($errors = $this->validate($data, $id)) {
return $this->error('Проверьте поля', $errors);
}
Model::update($id, $data);
if ((int) $request->rubric_id !== (int) $data['rubric_id']) {
return $this->success('Сохранено', array(
'redirect' => $this->base() . '/requests/' . $id . '?saved=1',
));
}
return $this->success('Сохранено');
}
/** POST /requests/{id}/copy — копия. */
public function copy(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$id = (int) ($params['id'] ?? 0);
if (!Model::find($id)) {
return $this->error('Запрос не найден', [], 404);
}
$newId = Model::copy($id);
return $this->success('Создана копия', ['redirect' => $this->base() . '/requests/' . $newId]);
}
/** POST /requests/{id}/delete — удалить. */
public function destroy(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$id = (int) ($params['id'] ?? 0);
if (!Model::find($id)) {
return $this->error('Запрос не найден', [], 404);
}
Model::delete($id);
return $this->success('Запрос удалён');
}
/** POST /requests/lint — проверка PHP-синтаксиса кода шаблона (ajax). */
public function lint(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$code = (string) Request::post('code', '');
if (!class_exists('App\\Adminx\\Templates\\Syntax')) {
return $this->error('Проверка недоступна', [], 501);
}
$r = \App\Adminx\Templates\Syntax::check($code);
if (!empty($r['ok'])) {
return $this->success($r['message'], ['data' => ['checked' => !empty($r['checked'])]]);
}
return $this->error($r['message'], ['code' => $r['output']]);
}
/** POST /requests/{id}/conditions/reorder — порядок условий (drag-and-drop). */
public function conditionReorder(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$id = (int) ($params['id'] ?? 0);
if (!Model::find($id)) {
return $this->error('Запрос не найден', [], 404);
}
$order = Request::post('items', Request::post('order', []));
Model::reorderConditions($id, is_array($order) ? $order : []);
return $this->success('Порядок сохранён');
}
/** GET /requests/alias — проверка алиаса (ajax). */
public function aliasCheck(array $params = [])
{
$alias = strtolower(trim(Request::getStr('alias')));
$id = Request::getInt('id');
$valid = (bool) preg_match('/^[a-z0-9_-]{1,20}$/', $alias);
return $this->success('', ['data' => [
'available' => $valid && !Model::aliasExists($alias, $id),
'valid' => $valid,
]]);
}
/** GET /requests/documents/picker — выбор документа для relation-условия. */
public function documentPicker(array $params = array())
{
return $this->success('', array('data' => array(
'items' => Model::documentPicker(
Request::getStr('q', ''),
Request::getStr('rubric_id', ''),
Request::getInt('limit', 30)
),
)));
}
/** POST /requests/{id}/conditions — сохранить условие. */
public function conditionSave(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$id = (int) ($params['id'] ?? 0);
if (!Model::find($id)) {
return $this->error('Запрос не найден', [], 404);
}
try {
$condId = Model::conditionSave($id, [
'id' => Request::postInt('cond_id'),
'condition_field_id' => Request::postInt('condition_field_id'),
'condition_compare' => Request::postStr('condition_compare'),
'condition_value' => (string) Request::post('condition_value', ''),
'condition_value_source' => Request::postStr('condition_value_source'),
'condition_value_key' => Request::postStr('condition_value_key'),
'condition_value_mode' => Request::postStr('condition_value_mode'),
'condition_value_constant' => (string) Request::post('condition_value_constant', ''),
'condition_join' => Request::postStr('condition_join'),
'condition_status' => Request::postBool('condition_status', false),
'condition_group_id' => Request::postInt('condition_group_id'),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), array(), 422);
}
return $this->success('Условие сохранено', ['data' => ['id' => $condId]]);
}
/** POST /requests/conditions/{id}/delete — удалить условие. */
public function conditionDelete(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
Model::conditionDelete((int) ($params['id'] ?? 0));
return $this->success('Условие удалено');
}
public function groupSave(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$requestId = (int) ($params['id'] ?? 0);
if (!Model::find($requestId)) {
return $this->error('Запрос не найден', [], 404);
}
$groupId = Model::groupSave($requestId, array(
'id' => Request::postInt('group_id'),
'parent_id' => Request::postInt('parent_id'),
'group_title' => Request::postStr('group_title'),
'group_operator' => Request::postStr('group_operator'),
));
if ($groupId <= 0) {
return $this->error('Не удалось сохранить группу', [], 422);
}
return $this->success('Группа сохранена', array('data' => array('id' => $groupId)));
}
public function groupDelete(array $params = [])
{
if (($resp = $this->guard()) !== null) {
return $resp;
}
$requestId = (int) ($params['id'] ?? 0);
$groupId = (int) ($params['groupId'] ?? 0);
if (!Model::groupDelete($requestId, $groupId)) {
return $this->error('Корневую или чужую группу удалить нельзя', [], 422);
}
return $this->success('Группа удалена. Ее содержимое перенесено выше.');
}
/** POST /requests/{id}/preview — первые строки через текущий executor. */
public function preview(array $params = [])
{
if (($resp = $this->guardPermission('view_requests')) !== null) {
return $resp;
}
$request = Model::find(isset($params['id']) ? (int) $params['id'] : 0);
if (!$request) {
return $this->error('Запрос не найден', array(), 404);
}
$contract = \App\Content\Requests\RequestResultContract::normalize(array(
'system' => Request::post('result_system', array()),
'fields' => Request::post('result_fields', array()),
), array_map(function ($field) { return (int) $field->Id; }, Model::rubricFields((int) $request->rubric_id)));
try {
$preview = (new RequestViewPreview())->run(
(int) $request->Id,
$contract,
Request::postInt('limit', 10),
RequestRendererRegistry::normalizePreviewCode(Request::postStr('renderer', Model::previewRenderer($request)))
);
} catch (\Throwable $e) {
return $this->error(ErrorReport::publicMessage(
'Предпросмотр запроса не выполнен',
$e,
'REQUEST_PREVIEW',
array('request_id' => (int) $request->Id)
), array(), 422);
}
$preview['plan'] = Model::previewPlan((int) $request->Id);
try {
$preview['executor_comparison'] = (new RequestExecutorComparator())->compare(
(int) $request->Id,
Request::postInt('limit', 10)
);
Model::recordNativeVerification((int) $request->Id, $preview['executor_comparison']);
} catch (\Throwable $e) {
$preview['executor_comparison'] = array(
'status' => 'error',
'matched' => false,
'reasons' => array(ErrorReport::publicMessage(
'Теневое сравнение не выполнено',
$e,
'REQUEST_EXECUTOR_COMPARE',
array('request_id' => (int) $request->Id)
)),
);
}
$preview['contract'] = $contract;
$preview['system_options'] = \App\Content\Requests\RequestResultContract::systemOptions();
$preview['field_options'] = array();
foreach (Model::rubricFields((int) $request->rubric_id) as $field) {
$preview['field_options'][(string) $field->Id] = array(
'title' => (string) $field->rubric_field_title,
'alias' => (string) $field->rubric_field_alias,
'type' => (string) $field->rubric_field_type,
);
}
if (!Permission::check('manage_requests')) {
unset($preview['sql']);
}
return $this->success('Предпросмотр обновлён', array('data' => $preview));
}
public function explainDocuments(array $params = array())
{
if (($resp = $this->guardPermission('view_requests')) !== null) { return $resp; }
$requestId = isset($params['id']) ? (int) $params['id'] : 0;
if (!Model::find($requestId)) { return $this->error('Запрос не найден', array(), 404); }
return $this->success('', array('data' => array(
'items' => Model::explainDocuments($requestId, Request::getStr('q', ''), Request::getInt('limit', 12)),
)));
}
public function explain(array $params = array())
{
if (($resp = $this->guardPermission('view_requests')) !== null) { return $resp; }
$requestId = isset($params['id']) ? (int) $params['id'] : 0;
$documentId = Request::postInt('document_id');
if ($requestId <= 0 || $documentId <= 0) {
return $this->error('Выберите документ для проверки', array('document_id' => 'Документ не выбран'), 422);
}
try {
$result = (new RequestMatchExplainer())->explain($requestId, $documentId);
} catch (\Throwable $e) {
return $this->error(ErrorReport::publicMessage(
'Не удалось объяснить результат запроса',
$e,
'REQUEST_EXPLAIN',
array('request_id' => $requestId, 'document_id' => $documentId)
), array(), 422);
}
return $this->success($result['matched'] ? 'Документ входит в запрос' : 'Документ исключён запросом', array('data' => $result));
}
// ------------------------------------------------------------------ //
protected function guard()
{
return $this->guardPermission('manage_requests');
}
protected function input()
{
$rubricId = Request::postInt('rubric_id');
$fieldIds = array_map(function ($field) { return (int) $field->Id; }, Model::rubricFields($rubricId));
$sortInput = Request::post('request_sort_rules', null);
if ($sortInput === null) {
$sortInput = array();
$fieldOrder = Request::postInt('request_order_by_nat');
$direction = Request::postStr('request_asc_desc') === 'DESC' ? 'DESC' : 'ASC';
if ($fieldOrder > 0) { $sortInput[] = array('source' => 'field', 'key' => $fieldOrder, 'direction' => $direction); }
$order = trim((string) Request::post('request_order_by', ''));
if (strtoupper($order) === 'RAND()') { $sortInput = array(array('source' => 'random', 'key' => 'RAND()', 'direction' => 'ASC')); }
elseif ($order !== '') { $sortInput[] = array('source' => 'document', 'key' => $order, 'direction' => $direction); }
$tie = \App\Frontend\RequestSort::tieBreaker(Request::postStr('request_order_tiebreaker'));
if ($tie !== '') { $sortInput[] = array('source' => 'document', 'key' => 'Id', 'direction' => $tie); }
}
$sortInspection = \App\Frontend\RequestSort::inspectRules($sortInput, $fieldIds);
$legacySort = \App\Frontend\RequestSort::legacyStorage($sortInspection['rules']);
$d = [
'request_alias' => strtolower(trim(Request::postStr('request_alias'))),
'rubric_id' => $rubricId,
'request_title' => trim((string) Request::post('request_title', '')),
'request_description' => trim((string) Request::post('request_description', '')),
'request_order_by' => $legacySort['request_order_by'],
'request_order_by_nat' => $legacySort['request_order_by_nat'],
'request_asc_desc' => $legacySort['request_asc_desc'],
'request_order_tiebreaker' => $legacySort['request_order_tiebreaker'],
'request_sort_rules' => \App\Frontend\RequestSort::encodeRules($sortInspection['rules']),
'request_items_per_page' => Request::postInt('request_items_per_page'),
'request_pagination' => Request::postInt('request_pagination'),
'request_cache_lifetime' => Request::postInt('request_cache_lifetime'),
'request_template_item' => (string) Request::post('request_template_item', ''),
'request_template_main' => (string) Request::post('request_template_main', ''),
'request_result_contract' => Model::encodeResultContract($rubricId, array(
'system' => Request::post('result_system', array()),
'fields' => Request::post('result_fields', array()),
)),
'request_preview_renderer' => RequestRendererRegistry::normalizePreviewCode(Request::postStr('request_preview_renderer', 'data_cards')),
'request_executor_mode' => in_array(Request::postStr('request_executor_mode', 'legacy'), array('legacy', 'shadow', 'native'), true)
? Request::postStr('request_executor_mode', 'legacy')
: 'legacy',
];
$d['_sort_errors'] = $sortInspection['errors'];
foreach (Model::$flags as $f) {
$d[$f] = Request::postBool($f, false) ? 1 : 0;
}
return $d;
}
protected function validate(array $data, $id)
{
$errors = [];
if ($data['request_alias'] === '') {
$errors['request_alias'] = 'Укажите алиас.';
} elseif (!preg_match('/^[a-z0-9_-]{1,20}$/', $data['request_alias'])) {
$errors['request_alias'] = 'Алиас: латиница/цифры/-/_, до 20 символов.';
} elseif (Model::aliasExists($data['request_alias'], (int) $id)) {
$errors['request_alias'] = 'Такой алиас уже используется.';
}
if ($data['request_title'] === '') {
$errors['request_title'] = 'Укажите название.';
}
if (!empty($data['_sort_errors'])) {
$errors['request_sort_rules'] = implode(' ', $data['_sort_errors']);
}
$paginationIds = array_map(function ($item) { return (int) $item['id']; }, Model::paginationOptions());
if (!$paginationIds) {
$errors['request_pagination'] = 'Сначала создайте шаблон пагинации в настройках.';
} elseif (!in_array((int) $data['request_pagination'], $paginationIds, true)) {
$errors['request_pagination'] = 'Выберите существующий шаблон пагинации.';
}
if ($data['request_executor_mode'] === 'native') {
if ((int) $id <= 0) {
$errors['request_executor_mode'] = 'Сначала сохраните запрос в режиме Legacy или Shadow.';
} else {
$plan = \App\Content\Requests\NativeRequestPlanCompiler::compile((int) $id, $data);
if (empty($plan['eligible'])) {
$errors['request_executor_mode'] = implode(' ', $plan['reasons']);
} elseif (!Model::nativeVerified(Model::find((int) $id), $plan)) {
$errors['request_executor_mode'] = 'Сначала сохраните изменения и получите точное совпадение Legacy/Native в предпросмотре.';
}
}
}
return $errors;
}
}