class AuthController extends Controller
{
+ /**
+ * Принудительный запуск без проверки кэша.
+ * Используется при прерванном предыдущем запуске: php yii auth/init --force=1
+ */
+ public bool $force = false;
+
+ public function options($actionID): array
+ {
+ return array_merge(parent::options($actionID), ['force']);
+ }
+
/**
* @throws Exception
* @throws \Exception
*/
public function actionInit() {
- if (Yii::$app->cache->get("dirtyAuthSettings") ?? true) {
- Yii::$app->cache->set("dirtyAuthSettings", false);
- echo "В настройках разрешений есть изменения. Продолжаю выполнение...\n";
- } else {
+ $isDirty = Yii::$app->cache->get("dirtyAuthSettings") ?? true;
+ if (!$this->force && !$isDirty) {
echo "Нет изменений в настройках разрешений\n";
return 'ok';
}
+ if ($this->force) {
+ echo "Принудительный запуск (--force).\n";
+ }
+
+ Yii::$app->cache->set("dirtyAuthSettings", false);
+ echo "В настройках разрешений есть изменения. Продолжаю выполнение...\n";
+
$auth = Yii::$app->authManager;
$auth->removeAll();
namespace app\controllers;
use Yii;
+use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
class AutoMarkController extends Controller
{
+ public function behaviors(): array
+ {
+ return [
+ 'access' => [
+ 'class' => AccessControl::class,
+ 'rules' => [
+ [
+ 'allow' => true,
+ 'roles' => ['@'],
+ 'matchCallback' => function ($rule, $action) {
+ return Yii::$app->user->can('automarkReview');
+ },
+ ],
+ ],
+ ],
+ ];
+ }
+
public function actionIndex(): string
{
$query = Products1cAutomarkPrediction::find()
$prediction->status = Products1cAutomarkPrediction::STATUS_APPROVED;
$prediction->approved_by = Yii::$app->user->id;
$prediction->updated_at = date('Y-m-d H:i:s');
- if ($prediction->save()) {
+ if (!$prediction->save()) {
+ $transaction->rollBack();
+ Yii::$app->session->setFlash('error', 'Не удалось сохранить предсказание.');
+ } else {
$service->applyApprovedPrediction($prediction->id);
+ $transaction->commit();
+ Yii::$app->session->setFlash('success', 'Разметка применена.');
}
- $transaction->commit();
- Yii::$app->session->setFlash('success', 'Разметка применена.');
} catch (\Exception) {
$transaction->rollBack();
Yii::$app->session->setFlash('error', 'Ошибка при применении разметки.');
} elseif ($action === 'reject') {
$prediction->status = Products1cAutomarkPrediction::STATUS_REJECTED;
$prediction->updated_at = date('Y-m-d H:i:s');
- $prediction->save();
- Yii::$app->session->setFlash('info', 'Предсказание отклонено.');
+ if ($prediction->save()) {
+ Yii::$app->session->setFlash('info', 'Предсказание отклонено.');
+ } else {
+ Yii::$app->session->setFlash('error', 'Не удалось отклонить предсказание.');
+ }
}
return $this->redirect(['index']);
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+use yii_app\records\AdminGroup;
+
+/**
+ * Добавить RBAC-разрешение automarkReview для авторазметки (ERP-292).
+ *
+ * Даёт доступ к /auto-mark/* группам:
+ * - 1 (DIRECTOR)
+ * - 10 (GROUP_RS_DIRECTOR — Директор розничной сети)
+ * - 81 (GROUP_IT)
+ * - 82 (GROUP_QC_MANAGER — Менеджер контроля качества)
+ *
+ * После применения ОБЯЗАТЕЛЬНО запустить:
+ * php yii auth/init
+ */
+class m260506_120000_add_automark_rbac_permissions extends Migration
+{
+ private const PERMISSION = 'automarkReview';
+
+ private const TARGET_GROUP_IDS = [
+ AdminGroup::DIRECTOR, // 1
+ AdminGroup::GROUP_RS_DIRECTOR, // 10
+ AdminGroup::GROUP_IT, // 81
+ AdminGroup::GROUP_KIK_MANAGER, // 82
+ ];
+
+ private const AUTOMARK_TABLE = 'erp24.products_1c_automark_predictions';
+ private const COMPOUND_INDEX = 'idx_automark_status_llm_verdict';
+
+ public function safeUp(): void
+ {
+ $this->addRbacPermission();
+ $this->addCompoundIndex();
+
+ Yii::$app->cache->set('dirtyAuthSettings', true);
+
+ echo "\n";
+ echo "==============================================================\n";
+ echo " ВАЖНО: теперь запустите:\n";
+ echo " php yii auth/init\n";
+ echo " чтобы разрешение automarkReview создалось в auth_item\n";
+ echo " и назначилось пользователям целевых групп.\n";
+ echo "==============================================================\n";
+ }
+
+ public function safeDown(): void
+ {
+ $this->removeRbacPermission();
+
+ if ($this->hasIndex(self::COMPOUND_INDEX)) {
+ $this->dropIndex(self::COMPOUND_INDEX, self::AUTOMARK_TABLE);
+ }
+
+ Yii::$app->cache->set('dirtyAuthSettings', true);
+
+ echo "\nПосле отката запустите: php yii auth/init\n";
+ }
+
+ private function addRbacPermission(): void
+ {
+ foreach (self::TARGET_GROUP_IDS as $groupId) {
+ $row = (new \yii\db\Query())
+ ->from('admin_group_rbac_config')
+ ->where(['admin_group_id' => $groupId])
+ ->one();
+
+ if ($row !== false && $row !== null) {
+ $existing = array_filter(explode(',', (string) $row['config']));
+ if (in_array(self::PERMISSION, $existing, true)) {
+ echo " Группа {$groupId}: разрешение уже есть, пропускаем\n";
+ continue;
+ }
+ $existing[] = self::PERMISSION;
+ $this->update(
+ 'admin_group_rbac_config',
+ ['config' => implode(',', $existing)],
+ ['admin_group_id' => $groupId]
+ );
+ echo " Группа {$groupId}: добавлено разрешение " . self::PERMISSION . "\n";
+ } else {
+ $this->insert('admin_group_rbac_config', [
+ 'admin_group_id' => $groupId,
+ 'config' => self::PERMISSION,
+ ]);
+ echo " Группа {$groupId}: создана запись с разрешением " . self::PERMISSION . "\n";
+ }
+ }
+ }
+
+ private function removeRbacPermission(): void
+ {
+ foreach (self::TARGET_GROUP_IDS as $groupId) {
+ $row = (new \yii\db\Query())
+ ->from('admin_group_rbac_config')
+ ->where(['admin_group_id' => $groupId])
+ ->one();
+
+ if ($row === false || $row === null) {
+ continue;
+ }
+
+ $filtered = array_values(array_diff(
+ array_filter(explode(',', (string) $row['config'])),
+ [self::PERMISSION]
+ ));
+
+ $this->update(
+ 'admin_group_rbac_config',
+ ['config' => implode(',', $filtered)],
+ ['admin_group_id' => $groupId]
+ );
+ }
+ }
+
+ private function addCompoundIndex(): void
+ {
+ $tableSchema = $this->db->getTableSchema(self::AUTOMARK_TABLE);
+ if ($tableSchema === null) {
+ return;
+ }
+
+ if (!$this->hasIndex(self::COMPOUND_INDEX)) {
+ $this->createIndex(
+ self::COMPOUND_INDEX,
+ self::AUTOMARK_TABLE,
+ ['status', 'llm_verdict']
+ );
+ echo " Создан составной индекс (status, llm_verdict)\n";
+ }
+ }
+
+ private function hasIndex(string $indexName): bool
+ {
+ $parts = explode('.', self::AUTOMARK_TABLE, 2);
+ $schema = $parts[0];
+ return (new \yii\db\Query())
+ ->from('pg_indexes')
+ ->where(['schemaname' => $schema, 'indexname' => $indexName])
+ ->exists($this->db);
+ }
+}
const GROUP_BUSH_DIRECTOR = 7;
const GROUP_OPERATIONAL_DIRECTOR = 51;
const GROUP_IT = 81;
+ const GROUP_KIK_MANAGER = 82; // Менеджер контроля качества
const GROUP_FINANCE_DIRECTOR = 9;
/**
private RuleBasedParser $ruleParser;
private SimilarityMatcher $matcher;
+ private ?array $corpusCache = null;
public function __construct()
{
private function loadCorpus(): array
{
- return Products1cNomenclature::find()
+ return $this->corpusCache ??= Products1cNomenclature::find()
->select(['id', 'name', 'category', 'subcategory', 'species', 'sort', 'type', 'size', 'color'])
->where(['not', ['category' => null]])
->asArray()
/**
* Верифицировать все pending-предсказания без LLM-вердикта.
+ * Загружает записи батчами (cursor by id) — не грузит всё в память сразу.
*
* @param callable|null $progressCallback function(string $event, array<string, mixed> $data): void
* @return int Количество верифицированных предсказаний
*/
public function verifyPending(?callable $progressCallback = null): int
{
- $predictions = Products1cAutomarkPrediction::find()
- ->with('product')
+ $totalPredictions = (int) Products1cAutomarkPrediction::find()
->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
->andWhere(['llm_verdict' => null])
- ->all();
+ ->count();
- if (empty($predictions)) {
+ if ($totalPredictions === 0) {
return 0;
}
- $batches = array_chunk($predictions, $this->batchSize);
- $totalBatches = count($batches);
- $totalPredictions = count($predictions);
+ $totalBatches = (int) ceil($totalPredictions / $this->batchSize);
$verified = 0;
$failedBatches = 0;
$firstError = null;
$processedPredictions = 0;
+ $batchNumber = 0;
+ $lastId = 0;
- foreach ($batches as $index => $batch) {
- $batchNumber = $index + 1;
- $items = $this->enrichBatch($batch);
- $payload = $this->buildEffectiveUserPrompt(self::buildBatchPayload($items));
- $batchIds = array_map(
- static fn(Products1cAutomarkPrediction $prediction): int => (int) $prediction->id,
- $batch
- );
+ do {
+ $batch = Products1cAutomarkPrediction::find()
+ ->with('product')
+ ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+ ->andWhere(['llm_verdict' => null])
+ ->andWhere(['>', 'id', $lastId])
+ ->orderBy(['id' => SORT_ASC])
+ ->limit($this->batchSize)
+ ->all();
+
+ if (empty($batch)) {
+ break;
+ }
+
+ $batchNumber++;
+ $lastId = (int) end($batch)->id;
+
+ /** @var array<int, Products1cAutomarkPrediction> $predictionsById */
+ $predictionsById = [];
+ foreach ($batch as $prediction) {
+ $predictionsById[(int) $prediction->id] = $prediction;
+ }
+
+ $items = $this->enrichBatch($batch);
+ $payload = $this->buildEffectiveUserPrompt(self::buildBatchPayload($items));
+ $batchIds = array_keys($predictionsById);
$progressCallback?->__invoke('batch_start', [
'batchNumber' => $batchNumber,
try {
$raw = $this->client->chat($this->buildEffectiveSystemPrompt(), $payload);
$results = self::parseVerifyResults($raw);
- $saved = $this->saveResults($results);
+ $saved = $this->saveResults($results, $predictionsById);
$verified += $saved;
$processedPredictions += count($batch);
]);
} catch (\RuntimeException $e) {
$failedBatches++;
- $firstError ??= sprintf('batch %d: %s', $index + 1, $e->getMessage());
+ $firstError ??= sprintf('batch %d: %s', $batchNumber, $e->getMessage());
$processedPredictions += count($batch);
$progressCallback?->__invoke('batch_error', [
'processedPredictions' => $processedPredictions,
'totalPredictions' => $totalPredictions,
]);
- continue;
}
- }
+ } while (count($batch) === $this->batchSize);
if ($verified === 0 && $failedBatches > 0) {
throw new \RuntimeException('LLM verification failed for all batches, first error: ' . $firstError);
/**
* @param LlmVerifyResult[] $results
+ * @param array<int, Products1cAutomarkPrediction> $predictionsById Уже загруженные из БД предсказания
*/
- private function saveResults(array $results): int
+ private function saveResults(array $results, array $predictionsById): int
{
$count = 0;
$now = date('Y-m-d H:i:s');
foreach ($results as $result) {
- $prediction = Products1cAutomarkPrediction::find()
- ->with('product')
- ->where(['id' => $result->predictionId])
- ->one();
-
+ $prediction = $predictionsById[$result->predictionId] ?? null;
if ($prediction === null) {
continue;
}