]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Каталог категорийного менеджера
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 22 Apr 2026 12:24:57 +0000 (15:24 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 22 Apr 2026 12:24:57 +0000 (15:24 +0300)
29 files changed:
erp24/commands/Products1cConceptController.php [new file with mode: 0644]
erp24/controllers/AssortmentLabelController.php [new file with mode: 0644]
erp24/controllers/Products1cNomenclatureActualityController.php
erp24/controllers/Products1cNomenclatureMarkupController.php [new file with mode: 0644]
erp24/docs/erp325_catmanager_presentation.html [new file with mode: 0644]
erp24/migrations/m241228_092653_add_target_date_column_to_sent_kogort_table.php [new file with mode: 0644]
erp24/migrations/m260420_120000_create_audit_log_table.php [new file with mode: 0644]
erp24/migrations/m260420_130000_create_assortment_tables.php [new file with mode: 0644]
erp24/migrations/m260420_140000_create_product_scores_table.php [new file with mode: 0644]
erp24/migrations/m260420_150000_create_store_product_ratings_table.php [new file with mode: 0644]
erp24/migrations/m260422_100000_add_classification_fields_to_nomenclature.php [new file with mode: 0644]
erp24/migrations/m260422_110000_add_catmanager_rbac.php [new file with mode: 0644]
erp24/migrations/m260422_120000_create_product_concepts_table.php [new file with mode: 0644]
erp24/records/AdminGroup.php
erp24/records/AssortmentLabel.php [new file with mode: 0644]
erp24/records/AuditLog.php [new file with mode: 0644]
erp24/records/ProductAssortment.php [new file with mode: 0644]
erp24/records/ProductConcept.php [new file with mode: 0644]
erp24/records/ProductScore.php [new file with mode: 0644]
erp24/records/Products1cNomenclature.php
erp24/records/Products1cNomenclatureActuality.php
erp24/records/StoreProductRating.php [new file with mode: 0644]
erp24/traits/AuditableTrait.php [new file with mode: 0644]
erp24/views/assortment-label/index.php [new file with mode: 0644]
erp24/views/products1c-nomenclature-actuality/index.php
erp24/views/products1c-nomenclature-markup/index.php [new file with mode: 0644]
erp24/web/js/assortmentLabel/index.js [new file with mode: 0644]
erp24/web/js/products1cNomenclatureActuality/index.js
erp24/web/js/products1cNomenclatureMarkup/index.js [new file with mode: 0644]

diff --git a/erp24/commands/Products1cConceptController.php b/erp24/commands/Products1cConceptController.php
new file mode 100644 (file)
index 0000000..d90ad34
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\commands;
+
+use Yii;
+use yii\console\Controller;
+use yii\console\ExitCode;
+
+/**
+ * ERP-330 CM-1.4: Синхронизация концепций из 1С.
+ *
+ * Источник: таблица bouquet_components.
+ * Если источник отсутствует — выход без ошибки (Phase 2).
+ *
+ * Cron: 0 3 * * * php yii products1c-concept/sync
+ */
+class Products1cConceptController extends Controller
+{
+    private const SOURCE_TABLE = 'bouquet_components';
+    private const TARGET_TABLE = 'product_concepts';
+
+    /**
+     * Truncate + insert концепций из bouquet_components → product_concepts.
+     */
+    public function actionSync(): int
+    {
+        $db = Yii::$app->db;
+
+        if ($db->getTableSchema(self::SOURCE_TABLE) === null) {
+            Yii::warning(
+                self::SOURCE_TABLE . ' не существует — синхронизация пропущена (Phase 2)',
+                'concept-sync'
+            );
+            $this->stdout("Источник " . self::SOURCE_TABLE . " отсутствует. Пропускаем.\n");
+            return ExitCode::OK;
+        }
+
+        $transaction = $db->beginTransaction();
+        try {
+            $db->createCommand('TRUNCATE TABLE ' . self::TARGET_TABLE)->execute();
+
+            $now = date('Y-m-d H:i:s');
+            $inserted = $db->createCommand(<<<SQL
+                INSERT INTO product_concepts (product_guid, concept_name, synced_at)
+                SELECT DISTINCT product_guid, concept_name, :now
+                FROM bouquet_components
+                WHERE product_guid IS NOT NULL
+                  AND concept_name IS NOT NULL
+                  AND concept_name <> ''
+            SQL, [':now' => $now])->execute();
+
+            $transaction->commit();
+
+            $this->stdout("Синхронизировано: {$inserted} концепций.\n");
+            Yii::info("concept-sync: {$inserted} rows inserted", 'concept-sync');
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            Yii::error('concept-sync failed: ' . $e->getMessage(), 'concept-sync');
+            $this->stderr("Ошибка: " . $e->getMessage() . "\n");
+            return ExitCode::UNSPECIFIED_ERROR;
+        }
+
+        return ExitCode::OK;
+    }
+}
diff --git a/erp24/controllers/AssortmentLabelController.php b/erp24/controllers/AssortmentLabelController.php
new file mode 100644 (file)
index 0000000..52a5eb8
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii\filters\AccessControl;
+use yii\filters\VerbFilter;
+use yii\web\BadRequestHttpException;
+use yii\web\Controller;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+use yii_app\records\AssortmentLabel;
+use yii_app\records\ProductAssortment;
+use yii_app\records\Products1cNomenclature;
+
+class AssortmentLabelController extends Controller
+{
+    public function behaviors(): array
+    {
+        return array_merge(parent::behaviors(), [
+            'access' => [
+                'class' => AccessControl::class,
+                'rules' => [
+                    [
+                        'allow'       => true,
+                        'actions'     => ['ajax-save', 'ajax-toggle-active', 'ajax-delete'],
+                        'permissions' => ['catmanager/manage'],
+                    ],
+                    [
+                        'allow'       => true,
+                        'actions'     => ['index', 'ajax-list'],
+                        'permissions' => ['catmanager/view'],
+                    ],
+                ],
+            ],
+            'verbs' => [
+                'class'   => VerbFilter::class,
+                'actions' => [
+                    'ajax-save'          => ['POST'],
+                    'ajax-toggle-active' => ['POST'],
+                    'ajax-delete'        => ['POST'],
+                ],
+            ],
+        ]);
+    }
+
+    public function actionIndex(): string
+    {
+        $totalProducts = (int)Products1cNomenclature::find()->count();
+        $withLabel     = (int)ProductAssortment::find()
+            ->select('product_guid')
+            ->distinct()
+            ->count();
+
+        $coverage = $totalProducts > 0
+            ? round($withLabel / $totalProducts * 100, 1)
+            : 0.0;
+
+        return $this->render('index', [
+            'totalProducts' => $totalProducts,
+            'withLabel'     => $withLabel,
+            'coverage'      => $coverage,
+        ]);
+    }
+
+    public function actionAjaxList(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $labels = AssortmentLabel::find()
+            ->orderBy(['is_active' => SORT_DESC, 'name' => SORT_ASC])
+            ->all();
+
+        $counts = ProductAssortment::find()
+            ->select(['label_id', 'cnt' => 'COUNT(DISTINCT product_guid)'])
+            ->groupBy('label_id')
+            ->asArray()
+            ->all();
+
+        $countMap = array_column($counts, 'cnt', 'label_id');
+
+        return [
+            'success' => true,
+            'labels'  => array_map(static fn(AssortmentLabel $l) => [
+                'id'           => $l->id,
+                'name'         => $l->name,
+                'channel_type' => $l->channel_type,
+                'color'        => $l->color,
+                'icon'         => $l->icon,
+                'is_active'    => (bool)$l->is_active,
+                'product_count' => (int)($countMap[$l->id] ?? 0),
+            ], $labels),
+        ];
+    }
+
+    public function actionAjaxSave(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post = Yii::$app->request->post();
+        $id   = !empty($post['id']) ? (int)$post['id'] : null;
+
+        $model = $id ? $this->findModel($id) : new AssortmentLabel();
+
+        $model->name         = trim($post['name'] ?? '');
+        $model->channel_type = $post['channel_type'] ?: null;
+        $model->color        = $post['color'] ?: null;
+        $model->icon         = trim($post['icon'] ?? '') ?: null;
+        $model->is_active    = !empty($post['is_active']);
+
+        if (!$model->validate()) {
+            return ['success' => false, 'message' => implode(', ', $model->getFirstErrors())];
+        }
+
+        if (!$model->save(false)) {
+            return ['success' => false, 'message' => 'Ошибка сохранения'];
+        }
+
+        return ['success' => true, 'message' => $id ? 'Лейбл обновлён' : 'Лейбл создан'];
+    }
+
+    public function actionAjaxToggleActive(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post  = Yii::$app->request->post();
+        $id    = !empty($post['id']) ? (int)$post['id'] : null;
+        $force = !empty($post['force']);
+
+        if (!$id) {
+            return ['success' => false, 'message' => 'Missing id'];
+        }
+
+        $model = $this->findModel($id);
+
+        if ($model->is_active && !$force) {
+            $count = (int)ProductAssortment::find()
+                ->where(['label_id' => $id])
+                ->select('product_guid')
+                ->distinct()
+                ->count();
+
+            if ($count > 0) {
+                return [
+                    'success'      => false,
+                    'needs_confirm' => true,
+                    'product_count' => $count,
+                    'message'      => "Лейбл используется у {$count} товаров. Деактивировать?",
+                ];
+            }
+        }
+
+        $model->is_active = !$model->is_active;
+
+        if (!$model->save()) {
+            return ['success' => false, 'message' => 'Ошибка сохранения'];
+        }
+
+        return [
+            'success'   => true,
+            'is_active' => (bool)$model->is_active,
+            'message'   => $model->is_active ? 'Лейбл активирован' : 'Лейбл деактивирован',
+        ];
+    }
+
+    public function actionAjaxDelete(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $id = (int)(Yii::$app->request->post('id') ?? 0);
+        if (!$id) {
+            return ['success' => false, 'message' => 'Missing id'];
+        }
+
+        $model = $this->findModel($id);
+
+        $count = (int)ProductAssortment::find()
+            ->where(['label_id' => $id])
+            ->select('product_guid')
+            ->distinct()
+            ->count();
+
+        if ($count > 0) {
+            return [
+                'success' => false,
+                'message' => "Нельзя удалить: лейбл используется у {$count} товаров. Сначала деактивируйте.",
+            ];
+        }
+
+        $model->delete();
+
+        return ['success' => true, 'message' => 'Лейбл удалён'];
+    }
+
+    protected function findModel(int $id): AssortmentLabel
+    {
+        $model = AssortmentLabel::findOne($id);
+        if ($model === null) {
+            throw new NotFoundHttpException('Лейбл не найден');
+        }
+        return $model;
+    }
+}
index ec1a43d03b5b7ae137726b7a95e6cfac116244f8..2fd7bd61e39cb6e64368ec153197fb51f98b8ceb 100644 (file)
@@ -4,17 +4,23 @@ namespace app\controllers;
 
 use Yii;
 use yii\db\Query;
+use yii\filters\AccessControl;
 use yii\web\BadRequestHttpException;
+use yii_app\records\ProductConcept;
 use yii\web\Response;
 use yii_app\api3\modules\v1\models\Sales;
 use yii_app\records\Products1cAdditionalCharacteristics;
 use yii_app\records\Products1cNomenclature;
+use yii_app\records\AssortmentLabel;
+use yii_app\records\ProductAssortment;
 use yii_app\records\Products1cNomenclatureActuality;
 use yii_app\records\Products1cNomenclatureActualitySearch;
 use yii\web\Controller;
 use yii\web\NotFoundHttpException;
 use yii\filters\VerbFilter;
 use yii_app\records\Products1cPropType;
+use yii_app\records\ProductScore;
+use yii_app\records\StoreProductRating;
 
 /**
  * Products1cNomenclatureActualityController implements the CRUD actions for Products1cNomenclatureActuality model.
@@ -29,10 +35,35 @@ class Products1cNomenclatureActualityController extends Controller
         return array_merge(
             parent::behaviors(),
             [
+                'access' => [
+                    'class' => AccessControl::class,
+                    'rules' => [
+                        [
+                            'allow'       => true,
+                            'actions'     => [
+                                'ajax-save-interval', 'ajax-save-assortment', 'ajax-remove-label',
+                                'ajax-save-score', 'ajax-add-test-mapping', 'ajax-bulk-assign',
+                                'delete', 'create', 'update', 'ajax-delete', 'add-activity',
+                            ],
+                            'permissions' => ['catmanager/manage'],
+                        ],
+                        [
+                            'allow'       => true,
+                            'actions'     => ['index', 'view', 'export-xlsx', 'ajax-intervals', 'ajax-labels', 'ajax-scores'],
+                            'permissions' => ['catmanager/view'],
+                        ],
+                    ],
+                ],
                 'verbs' => [
                     'class' => VerbFilter::className(),
                     'actions' => [
-                        'delete' => ['POST'],
+                        'delete'                => ['POST'],
+                        'ajax-save-interval'    => ['POST'],
+                        'ajax-save-assortment'    => ['POST'],
+                        'ajax-remove-label'       => ['POST'],
+                        'ajax-save-score'         => ['POST'],
+                        'ajax-add-test-mapping'   => ['POST'],
+                        'ajax-bulk-assign'        => ['POST'],
                     ],
                 ],
             ]
@@ -50,29 +81,28 @@ class Products1cNomenclatureActualityController extends Controller
             'category', 'subcategory', 'species',
             'type', 'color', 'sort', 'size',
             'date_from', 'date_to',
+            'search', 'pageSize', 'sort_by',
         ]);
         $filter->addRule([
             'category', 'subcategory', 'species',
             'type', 'color', 'sort', 'size',
             'date_from', 'date_to',
+            'search', 'sort_by',
         ], 'safe');
+        $filter->addRule(['pageSize'], 'integer');
         $filter->addRule(['onlyActive', 'onlyInactive'], 'boolean');
         $filter->load(Yii::$app->request->get());
 
-
-        if (Yii::$app->request->isPost && $post = Yii::$app->request->post('actuality', [])) {
-            $this->processBatchActuality($post);
-            Yii::$app->session->setFlash('success', 'Данные по актуальности успешно сохранены.');
-            return $this->refresh();
-        }
-
+        $pageSize = in_array((int)$filter->pageSize, [50, 100, 500]) ? (int)$filter->pageSize : 50;
+        $sortBy   = in_array($filter->sort_by, ['name', 'date_actuality']) ? $filter->sort_by : 'name';
 
         $emptyQuery = Products1cNomenclature::find()->where('0=1');
         $dataProvider = new \yii\data\ActiveDataProvider([
             'query' => $emptyQuery,
-            'pagination' => ['pageSize' => 50],
+            'pagination' => ['pageSize' => $pageSize],
             'sort' => ['defaultOrder' => ['name' => SORT_ASC]],
         ]);
+        $counters = null;
         $attrMap = [
             'type' => ['type', 'тип'],
             'color' => ['цвет', 'color'],
@@ -90,7 +120,8 @@ class Products1cNomenclatureActualityController extends Controller
             $filter->date_from,
             $filter->date_to,
             $filter->onlyActive,
-            $filter->onlyInactive
+            $filter->onlyInactive,
+            $filter->search,
         ]);
         if ($filtersUsed) {
             $query = Products1cNomenclature::find()->alias('n');
@@ -120,6 +151,10 @@ class Products1cNomenclatureActualityController extends Controller
                 }
             }
 
+            if ($filter->search) {
+                $query->andWhere(['ilike', 'n.name', $filter->search]);
+            }
+
             if (!empty($filter->onlyActive)) {
                 $query->andWhere(['exists',
                     Products1cNomenclatureActuality::find()
@@ -156,11 +191,9 @@ class Products1cNomenclatureActualityController extends Controller
                     ->where('a.guid = n.id');
 
                 if ($hasDateFrom && !$hasDateTo) {
-                    $dateExists//->andWhere(['a.date_from' => $dateFrom])
-                        ->andWhere(['>=', 'a.date_to', $dateFrom]);;
+                    $dateExists->andWhere(['>=', 'a.date_to', $dateFrom]);
                 } elseif (!$hasDateFrom && $hasDateTo) {
-                    $dateExists//->andWhere(['a.date_to' => $dateTo])
-                        ->andWhere(['<=', 'a.date_from', $dateTo]);
+                    $dateExists->andWhere(['<=', 'a.date_from', $dateTo]);
                 } else {
                     $dateExists
                         ->andWhere(['>=', 'a.date_to',   $dateFrom])
@@ -172,58 +205,91 @@ class Products1cNomenclatureActualityController extends Controller
                 } else {
                     $query->andWhere(['exists', $dateExists->select(new \yii\db\Expression('1'))]);
                 }
-
-                $query->with(['actualities' => function ($subQuery) use ($hasDateFrom, $hasDateTo, $dateFrom, $dateTo) {
-                    if ($hasDateFrom && !$hasDateTo) {
-                        $subQuery//->andWhere(['date_from' => $dateFrom])
-                            ->andWhere(['>=', 'date_to', $dateFrom]);
-                    } elseif (!$hasDateFrom && $hasDateTo) {
-                        $subQuery//->andWhere(['date_to' => $dateTo])
-                            ->andWhere(['<=', 'date_from', $dateTo]);
-                    } else {
-                        $subQuery->andWhere(['>=', 'date_to',   $dateFrom])
-                            ->andWhere(['<=', 'date_from', $dateTo]);
-                    }
-                    $subQuery->orderBy(['date_from' => SORT_ASC]);
-                }]);
-            } else {
-                $query->with(['actualities' => function ($q) {
-                    $q->orderBy(['date_from' => SORT_ASC]);
-                }]);
             }
 
-
-
-
+            $query->with(['actualities' => fn($q) => $q->orderBy(['date_from' => SORT_ASC])]);
             $products = $query->orderBy(['n.name' => SORT_ASC])->all();
 
             $rows = [];
             foreach ($products as $product) {
-                $acts = $product->actualities;
-                if ($acts) {
-                    foreach ($acts as $act) {
-                        $rows[] = [
-                            'product' => $product,
-                            'actuality' => $act,
-                        ];
+                $rows[] = [
+                    'product'     => $product,
+                    'actualities' => $product->actualities ?? [],
+                    'labels'      => [],
+                    'concepts'    => [],
+                ];
+            }
+
+            $guids = [];
+            if (!empty($rows)) {
+                $guids = array_map(static fn($r) => $r['product']->id, $rows);
+                $assortments = ProductAssortment::find()
+                    ->with('label')
+                    ->where(['product_guid' => $guids])
+                    ->all();
+                $labelsByGuid = [];
+                foreach ($assortments as $a) {
+                    if ($a->label) {
+                        $labelsByGuid[$a->product_guid][] = $a->label;
+                    }
+                }
+                foreach ($rows as &$r) {
+                    $r['labels'] = $labelsByGuid[$r['product']->id] ?? [];
+                }
+                unset($r);
+
+                // Концепции из 1С (ERP-330): graceful fallback если таблица не создана
+                $conceptsByGuid = [];
+                if (Yii::$app->db->getTableSchema(ProductConcept::tableName()) !== null) {
+                    $concepts = ProductConcept::find()
+                        ->where(['product_guid' => $guids])
+                        ->asArray()
+                        ->all();
+                    foreach ($concepts as $c) {
+                        $conceptsByGuid[$c['product_guid']][] = $c['concept_name'];
                     }
-                } else {
-                    $rows[] = [
-                        'product' => $product,
-                        'actuality' => null,
-                    ];
                 }
+                foreach ($rows as &$r) {
+                    $r['concepts'] = $conceptsByGuid[$r['product']->id] ?? [];
+                }
+                unset($r);
+            }
+
+            // Sort by date_actuality: descending by latest date_to
+            if ($sortBy === 'date_actuality') {
+                usort($rows, static function (array $a, array $b) {
+                    $aMax = empty($a['actualities']) ? '' : max(array_map(static fn($x) => $x->date_to, $a['actualities']));
+                    $bMax = empty($b['actualities']) ? '' : max(array_map(static fn($x) => $x->date_to, $b['actualities']));
+                    return strcmp($bMax, $aMax);
+                });
+            }
+
+            // Counter cards
+            $counters = ['total' => count($rows), 'active' => 0, 'no_labels' => 0, 'no_score' => null];
+            foreach ($rows as $r) {
+                $hasActive = false;
+                foreach ($r['actualities'] as $act) {
+                    if ($act->getStatus() === 'active') { $hasActive = true; break; }
+                }
+                if ($hasActive) { $counters['active']++; }
+                if (empty($r['labels'])) { $counters['no_labels']++; }
+            }
+            if (!empty($guids) && Yii::$app->db->getTableSchema('erp24.product_mappings') !== null) {
+                $guidsWithScore = (new Query())
+                    ->select('pm.product_guid')
+                    ->from(['pm' => 'erp24.product_mappings'])
+                    ->innerJoin(['ps' => 'product_scores'], 'ps.mapping_id = pm.id')
+                    ->where(['pm.product_guid' => $guids])
+                    ->distinct()
+                    ->column();
+                $counters['no_score'] = count($rows) - count(array_intersect($guids, $guidsWithScore));
             }
 
             $dataProvider = new \yii\data\ArrayDataProvider([
                 'allModels' => $rows,
-                'pagination' => ['pageSize' => 1000],
+                'pagination' => ['pageSize' => $pageSize],
                 'sort' => [
-                    'attributes' => [
-                        'product.name',
-                        'actuality.date_from',
-                        'actuality.date_to'
-                    ],
+                    'attributes'   => ['product.name'],
                     'defaultOrder' => ['product.name' => SORT_ASC],
                 ],
             ]);
@@ -264,18 +330,140 @@ class Products1cNomenclatureActualityController extends Controller
         }
 
         return $this->render('index', [
-            'filter' => $filter,
+            'filter'       => $filter,
             'dataProvider' => $dataProvider,
-            'categories' => array_combine($categories, $categories),
+            'categories'   => array_combine($categories, $categories),
             'subcategories' => array_combine($subcategories, $subcategories),
-            'species' => array_combine($species, $species),
-            'types' => array_combine($lists['type'], $lists['type']),
-            'colors' => array_combine($lists['color'], $lists['color']),
-            'sorts' => array_combine($lists['sort'], $lists['sort']),
-            'sizes' => array_combine($lists['size'], $lists['size']),
+            'species'      => array_combine($species, $species),
+            'types'        => array_combine($lists['type'], $lists['type']),
+            'colors'       => array_combine($lists['color'], $lists['color']),
+            'sorts'        => array_combine($lists['sort'], $lists['sort']),
+            'sizes'        => array_combine($lists['size'], $lists['size']),
+            'counters'     => $counters,
+            'pageSize'     => $pageSize,
+            'sortBy'       => $sortBy,
         ]);
     }
 
+    public function actionExportXlsx()
+    {
+        $filter = new \yii\base\DynamicModel([
+            'category', 'subcategory', 'species',
+            'type', 'color', 'sort', 'size',
+            'date_from', 'date_to', 'search',
+        ]);
+        $filter->addRule([
+            'category', 'subcategory', 'species',
+            'type', 'color', 'sort', 'size',
+            'date_from', 'date_to', 'search',
+        ], 'safe');
+        $filter->addRule(['onlyActive', 'onlyInactive'], 'boolean');
+        $filter->load(Yii::$app->request->get());
+
+        $attrMap = [
+            'type' => ['type', 'тип'],
+            'color' => ['цвет', 'color'],
+            'sort' => ['sort', 'сорт'],
+            'size' => ['size', 'размер'],
+        ];
+
+        $query = Products1cNomenclature::find()->alias('n');
+
+        foreach (['category', 'subcategory', 'species'] as $attr) {
+            if ($filter->$attr) { $query->andWhere(["n.$attr" => $filter->$attr]); }
+        }
+
+        foreach (['type', 'color', 'sort', 'size'] as $attr) {
+            if ($filter->$attr) {
+                $propIds = Products1cPropType::find()->select('id')->andWhere(['name' => $attrMap[$attr]])->column();
+                $ids = Products1cAdditionalCharacteristics::find()->select('product_id')->distinct()
+                    ->where(['property_id' => $propIds, 'value' => $filter->$attr])->column();
+                if (empty($ids)) { $query->andWhere('0=1'); break; }
+                $query->andWhere(['n.id' => $ids]);
+            }
+        }
+
+        if ($filter->search) {
+            $query->andWhere(['ilike', 'n.name', $filter->search]);
+        }
+
+        if (!empty($filter->onlyActive)) {
+            $query->andWhere(['exists',
+                Products1cNomenclatureActuality::find()->where('guid = n.id')->select(new \yii\db\Expression('1'))
+            ]);
+        } elseif (!empty($filter->onlyInactive)) {
+            $query->andWhere(['not exists',
+                Products1cNomenclatureActuality::find()->where('guid = n.id')->select(new \yii\db\Expression('1'))
+            ]);
+        }
+
+        if ($filter->date_from || $filter->date_to) {
+            $hasDateFrom = !empty($filter->date_from);
+            $hasDateTo   = !empty($filter->date_to);
+            $dateFrom = $hasDateFrom ? (new \DateTime("{$filter->date_from}-01"))->setTime(0,0,0)->format('Y-m-d H:i:s') : null;
+            $dateTo   = $hasDateTo   ? (new \DateTime("{$filter->date_to}-01"))->modify('last day of this month')->setTime(23,59,59)->format('Y-m-d H:i:s') : null;
+            $dateExists = Products1cNomenclatureActuality::find()->alias('a')->where('a.guid = n.id');
+            if ($hasDateFrom && !$hasDateTo)      { $dateExists->andWhere(['>=', 'a.date_to',   $dateFrom]); }
+            elseif (!$hasDateFrom && $hasDateTo)  { $dateExists->andWhere(['<=', 'a.date_from', $dateTo]); }
+            else { $dateExists->andWhere(['>=', 'a.date_to', $dateFrom])->andWhere(['<=', 'a.date_from', $dateTo]); }
+            $cond = !empty($filter->onlyInactive) ? 'not exists' : 'exists';
+            $query->andWhere([$cond, $dateExists->select(new \yii\db\Expression('1'))]);
+        }
+
+        $query->with(['actualities' => fn($q) => $q->orderBy(['date_from' => SORT_ASC])]);
+        $products = $query->orderBy(['n.name' => SORT_ASC])->all();
+
+        if (!empty($products)) {
+            $guids = array_column(array_map(static fn($p) => ['id' => $p->id], $products), 'id');
+            $assortments = ProductAssortment::find()->with('label')->where(['product_guid' => $guids])->all();
+            $labelsByGuid = [];
+            foreach ($assortments as $a) {
+                if ($a->label) { $labelsByGuid[$a->product_guid][] = $a->label->name; }
+            }
+        }
+
+        $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('Актуальность');
+
+        $headers = ['Наименование', 'GUID', 'Интервалы актуальности', 'Лейблы', 'Статус'];
+        foreach ($headers as $col => $title) {
+            $sheet->setCellValueByColumnAndRow($col + 1, 1, $title);
+            $sheet->getColumnDimensionByColumn($col + 1)->setAutoSize(true);
+        }
+        $sheet->getStyle('A1:E1')->getFont()->setBold(true);
+
+        $rowNum = 2;
+        foreach ($products as $product) {
+            $acts = $product->actualities ?? [];
+            $intervalsText = implode('; ', array_map(
+                static fn($a) => substr($a->date_from, 0, 7) . ' – ' . substr($a->date_to, 0, 7),
+                $acts
+            ));
+            $hasActive = false;
+            foreach ($acts as $act) {
+                if ($act->getStatus() === 'active') { $hasActive = true; break; }
+            }
+            $labelsText = implode(', ', $labelsByGuid[$product->id] ?? []);
+
+            $sheet->setCellValueByColumnAndRow(1, $rowNum, $product->name);
+            $sheet->setCellValueByColumnAndRow(2, $rowNum, $product->id);
+            $sheet->setCellValueByColumnAndRow(3, $rowNum, $intervalsText);
+            $sheet->setCellValueByColumnAndRow(4, $rowNum, $labelsText);
+            $sheet->setCellValueByColumnAndRow(5, $rowNum, $hasActive ? 'Активен' : 'Не активен');
+            $rowNum++;
+        }
+
+        $filename = 'actuality_' . date('Y-m-d') . '.xlsx';
+        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+        header('Content-Disposition: attachment; filename="' . $filename . '"');
+        header('Cache-Control: max-age=0');
+
+        $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
+        $writer->save('php://output');
+        Yii::$app->end();
+    }
+
     public function actionAddActivity()
     {
         $request = Yii::$app->request;
@@ -556,6 +744,424 @@ class Products1cNomenclatureActualityController extends Controller
         }
     }
 
+    public function actionAjaxIntervals(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid = Yii::$app->request->get('guid');
+        if (!$guid) {
+            throw new BadRequestHttpException('Missing guid');
+        }
+
+        $intervals = Products1cNomenclatureActuality::find()
+            ->where(['guid' => $guid])
+            ->orderBy(['date_from' => SORT_ASC])
+            ->all();
+
+        return [
+            'success'   => true,
+            'intervals' => array_map(static fn($i) => [
+                'id'       => $i->id,
+                'date_from' => $i->date_from,
+                'date_to'   => $i->date_to,
+                'from_ym'  => substr($i->date_from, 0, 7),
+                'to_ym'    => $i->date_to ? substr($i->date_to, 0, 7) : null,
+                'status'   => $i->getStatus(),
+                'label'    => $i->getLabel(),
+            ], $intervals),
+        ];
+    }
+
+    public function actionAjaxSaveInterval(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post   = Yii::$app->request->post();
+        $guid   = $post['guid'] ?? null;
+        $id     = !empty($post['id']) ? (int)$post['id'] : null;
+        $fromYm = $post['date_from'] ?? null;
+        $toYm   = $post['date_to']   ?? null;
+
+        if (!$guid || !$fromYm || !$toYm) {
+            return ['success' => false, 'message' => 'Заполните все поля'];
+        }
+
+        $from = (new \DateTime("{$fromYm}-01"))->setTime(0, 0, 0)->format('Y-m-d H:i:s');
+        $to   = (new \DateTime("{$toYm}-01"))->modify('last day of this month')->setTime(23, 59, 59)->format('Y-m-d H:i:s');
+
+        if ($from > $to) {
+            return ['success' => false, 'message' => 'Дата начала не может быть позже даты окончания'];
+        }
+
+        if (!$id) {
+            $count = Products1cNomenclatureActuality::find()->where(['guid' => $guid])->count();
+            if ($count >= 10) {
+                return ['success' => false, 'message' => 'Максимум 10 интервалов на товар'];
+            }
+        }
+
+        $overlapQuery = Products1cNomenclatureActuality::find()
+            ->where(['guid' => $guid])
+            ->andWhere('date_to >= :from', [':from' => $from])
+            ->andWhere('date_from <= :to',   [':to'   => $to]);
+        if ($id) {
+            $overlapQuery->andWhere(['<>', 'id', $id]);
+        }
+        if ($overlapQuery->exists()) {
+            return ['success' => false, 'message' => 'Интервал пересекается с существующим'];
+        }
+
+        $userId = Yii::$app->user->id;
+        $now    = date('Y-m-d H:i:s');
+
+        if ($id) {
+            $model = $this->findModel($id);
+            $model->updated_at = $now;
+            $model->updated_by = $userId;
+        } else {
+            $model = new Products1cNomenclatureActuality([
+                'guid'       => $guid,
+                'created_at' => $now,
+                'created_by' => $userId,
+            ]);
+        }
+        $model->date_from = $from;
+        $model->date_to   = $to;
+
+        if (!$model->save()) {
+            return ['success' => false, 'message' => implode(', ', $model->getFirstErrors())];
+        }
+
+        return ['success' => true, 'message' => 'Сохранено'];
+    }
+
+    public function actionAjaxLabels(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid = Yii::$app->request->get('guid');
+        if (!$guid) {
+            throw new BadRequestHttpException('Missing guid');
+        }
+
+        $assigned = ProductAssortment::find()
+            ->select('label_id')
+            ->where(['product_guid' => $guid])
+            ->column();
+
+        $labels = AssortmentLabel::findActive();
+
+        return [
+            'success' => true,
+            'labels'  => array_map(static fn($l) => [
+                'id'           => $l->id,
+                'name'         => $l->name,
+                'channel_type' => $l->channel_type,
+                'color'        => $l->color,
+                'is_assigned'  => in_array($l->id, $assigned, true),
+            ], $labels),
+        ];
+    }
+
+    public function actionAjaxSaveAssortment(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post      = Yii::$app->request->post();
+        $guid      = $post['guid'] ?? null;
+        $labelIds  = array_map('intval', (array)($post['label_ids'] ?? []));
+
+        if (!$guid) {
+            return ['success' => false, 'message' => 'Missing guid'];
+        }
+
+        $current = ProductAssortment::find()
+            ->where(['product_guid' => $guid])
+            ->all();
+
+        $currentIds = array_map(static fn($r) => $r->label_id, $current);
+
+        $toDelete = array_diff($currentIds, $labelIds);
+        $toInsert = array_diff($labelIds, $currentIds);
+
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            foreach ($current as $row) {
+                if (in_array($row->label_id, $toDelete, true)) {
+                    $row->delete();
+                }
+            }
+
+            foreach ($toInsert as $lid) {
+                $row = new ProductAssortment([
+                    'product_guid' => $guid,
+                    'label_id'     => $lid,
+                ]);
+                if (!$row->save()) {
+                    $transaction->rollBack();
+                    return ['success' => false, 'message' => implode(', ', $row->getFirstErrors())];
+                }
+            }
+
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            return ['success' => false, 'message' => $e->getMessage()];
+        }
+
+        $assigned = ProductAssortment::find()
+            ->with('label')
+            ->where(['product_guid' => $guid])
+            ->all();
+
+        return [
+            'success' => true,
+            'labels'  => array_map(static fn($r) => [
+                'id'           => $r->label->id,
+                'name'         => $r->label->name,
+                'color'        => $r->label->color,
+                'channel_type' => $r->label->channel_type,
+            ], $assigned),
+        ];
+    }
+
+    public function actionAjaxRemoveLabel(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post     = Yii::$app->request->post();
+        $guid     = $post['guid']     ?? null;
+        $labelId  = !empty($post['label_id']) ? (int)$post['label_id'] : null;
+
+        if (!$guid || !$labelId) {
+            return ['success' => false, 'message' => 'Missing guid or label_id'];
+        }
+
+        $row = ProductAssortment::findOne(['product_guid' => $guid, 'label_id' => $labelId]);
+        if (!$row) {
+            return ['success' => false, 'message' => 'Not found'];
+        }
+
+        $row->delete();
+
+        return ['success' => true];
+    }
+
+    public function actionAjaxBulkAssign(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post     = Yii::$app->request->post();
+        $guids    = array_values(array_filter((array)($post['guids'] ?? [])));
+        $labelIds = array_values(array_filter(array_map('intval', (array)($post['label_ids'] ?? []))));
+        $action   = $post['action'] ?? 'add';
+
+        if (empty($guids) || empty($labelIds)) {
+            return ['success' => false, 'message' => 'Не выбраны товары или лейблы'];
+        }
+
+        if (!in_array($action, ['add', 'remove'], true)) {
+            return ['success' => false, 'message' => 'Неизвестное действие'];
+        }
+
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            if ($action === 'add') {
+                foreach ($guids as $guid) {
+                    $existingIds = ProductAssortment::find()
+                        ->select('label_id')
+                        ->where(['product_guid' => $guid, 'label_id' => $labelIds])
+                        ->column();
+
+                    foreach (array_diff($labelIds, $existingIds) as $labelId) {
+                        $row = new ProductAssortment([
+                            'product_guid' => $guid,
+                            'label_id'     => (int)$labelId,
+                        ]);
+                        if (!$row->save()) {
+                            $transaction->rollBack();
+                            return ['success' => false, 'message' => implode(', ', $row->getFirstErrors())];
+                        }
+                    }
+                }
+            } else {
+                ProductAssortment::deleteAll(['product_guid' => $guids, 'label_id' => $labelIds]);
+            }
+
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            return ['success' => false, 'message' => $e->getMessage()];
+        }
+
+        $allAssortments = ProductAssortment::find()
+            ->with('label')
+            ->where(['product_guid' => $guids])
+            ->all();
+
+        $resultsByGuid = array_fill_keys($guids, []);
+        foreach ($allAssortments as $a) {
+            if ($a->label) {
+                $resultsByGuid[$a->product_guid][] = [
+                    'id'    => $a->label->id,
+                    'name'  => $a->label->name,
+                    'color' => $a->label->color,
+                ];
+            }
+        }
+
+        return [
+            'success'       => true,
+            'message'       => $action === 'add' ? 'Лейблы добавлены' : 'Лейблы убраны',
+            'updated_count' => count($guids),
+            'results'       => $resultsByGuid,
+        ];
+    }
+
+    public function actionAjaxScores(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid = Yii::$app->request->get('guid');
+        if (!$guid) {
+            throw new BadRequestHttpException('Missing guid');
+        }
+
+        $mappingsTableExists = Yii::$app->db->getTableSchema('erp24.product_mappings') !== null;
+
+        if (!$mappingsTableExists) {
+            return ['success' => true, 'mappings' => [], 'avg_store_score' => null, 'stub_mode' => true];
+        }
+
+        $mappings = (new Query())
+            ->select([
+                'pm.id',
+                'pm.supplier_product_name',
+                'pm.deleted_at',
+                's.name AS supplier_name',
+                'pl.name AS plantation_name',
+            ])
+            ->from(['pm' => 'erp24.product_mappings'])
+            ->leftJoin(['s' => 'erp24.suppliers'], 's.id = pm.supplier_id')
+            ->leftJoin(['pl' => 'erp24.plantations'], 'pl.id = pm.plantation_id')
+            ->where(['pm.product_guid' => $guid])
+            ->orderBy(['pm.deleted_at' => SORT_ASC, 'pm.id' => SORT_ASC])
+            ->all();
+
+        $mappingIds = array_column($mappings, 'id');
+
+        $scores = $mappingIds
+            ? ProductScore::find()->where(['mapping_id' => $mappingIds])->indexBy('mapping_id')->all()
+            : [];
+
+        $avgStoreScore = StoreProductRating::avgForProduct($guid);
+
+        return [
+            'success'         => true,
+            'avg_store_score' => $avgStoreScore,
+            'mappings'        => array_map(static function (array $m) use ($scores) {
+                /** @var ProductScore|null $score */
+                $score = $scores[$m['id']] ?? null;
+                return [
+                    'id'                   => $m['id'],
+                    'supplier_name'        => $m['supplier_name'] ?? '—',
+                    'plantation_name'      => $m['plantation_name'],
+                    'supplier_product_name' => $m['supplier_product_name'],
+                    'deleted_at'           => $m['deleted_at'],
+                    'score'                => $score ? $score->score : null,
+                    'comment'              => $score ? $score->comment : null,
+                    'km_comment_at'        => $score ? $score->km_comment_at : null,
+                ];
+            }, $mappings),
+        ];
+    }
+
+    public function actionAjaxSaveScore(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post      = Yii::$app->request->post();
+        $mappingId = !empty($post['mapping_id']) ? (int)$post['mapping_id'] : null;
+        $score     = isset($post['score']) && $post['score'] !== '' ? (int)$post['score'] : null;
+        $comment   = isset($post['comment']) ? trim($post['comment']) : null;
+
+        if (!$mappingId) {
+            return ['success' => false, 'message' => 'Missing mapping_id'];
+        }
+
+        if ($score !== null && ($score < 1 || $score > 5)) {
+            return ['success' => false, 'message' => 'Оценка должна быть от 1 до 5'];
+        }
+
+        $userId = Yii::$app->user->id;
+        $now    = date('Y-m-d H:i:s');
+
+        $model = ProductScore::findByMapping($mappingId);
+        if (!$model) {
+            $model = new ProductScore([
+                'mapping_id' => $mappingId,
+                'km_user_id' => $userId,
+            ]);
+        }
+
+        $model->score    = $score;
+        $model->comment  = $comment ?: null;
+        $model->km_user_id   = $userId;
+        $model->km_comment_at = ($comment !== null) ? $now : $model->km_comment_at;
+
+        if (!$model->save()) {
+            return ['success' => false, 'message' => implode(', ', $model->getFirstErrors())];
+        }
+
+        return ['success' => true, 'message' => 'Сохранено'];
+    }
+
+    public function actionAjaxAddTestMapping(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        if (!YII_DEBUG) {
+            return ['success' => false, 'message' => 'Доступно только в режиме разработки'];
+        }
+
+        $guid        = Yii::$app->request->post('guid');
+        $productName = trim(Yii::$app->request->post('product_name', 'Тестовый товар'));
+
+        if (!$guid) {
+            return ['success' => false, 'message' => 'Missing guid'];
+        }
+
+        $db = Yii::$app->db;
+
+        if ($db->getTableSchema('erp24.product_mappings') === null) {
+            return ['success' => false, 'message' => 'Таблица product_mappings не найдена. Запустите миграции ERP-300.'];
+        }
+
+        // Используем первого поставщика или создаём тестового
+        $supplierId = (new Query())->select('id')->from('erp24.suppliers')->scalar();
+        if (!$supplierId) {
+            return ['success' => false, 'message' => 'Нет поставщиков в справочнике. Добавьте через /supplier.'];
+        }
+
+        $userId = Yii::$app->user->id;
+        $now    = date('Y-m-d H:i:s');
+
+        try {
+            $db->createCommand()->insert('erp24.product_mappings', [
+                'product_guid'          => $guid,
+                'supplier_id'           => $supplierId,
+                'supplier_product_name' => $productName,
+                'quant'                 => 1,
+                'created_by'            => $userId,
+                'created_at'            => $now,
+            ])->execute();
+        } catch (\Throwable $e) {
+            return ['success' => false, 'message' => $e->getMessage()];
+        }
+
+        return ['success' => true, 'message' => 'Тестовый маппинг добавлен'];
+    }
+
     /**
      * Displays a single Products1cNomenclatureActuality model.
      * @param int $id ID
diff --git a/erp24/controllers/Products1cNomenclatureMarkupController.php b/erp24/controllers/Products1cNomenclatureMarkupController.php
new file mode 100644 (file)
index 0000000..1e83290
--- /dev/null
@@ -0,0 +1,342 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii\data\ActiveDataProvider;
+use yii\base\DynamicModel;
+use yii\filters\AccessControl;
+use yii\filters\VerbFilter;
+use yii\web\BadRequestHttpException;
+use yii\web\Controller;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+use yii_app\records\AuditLog;
+use yii_app\records\Products1cNomenclature;
+
+class Products1cNomenclatureMarkupController extends Controller
+{
+    public const STATUS_NOT_NEEDED   = 'not_needed';
+    public const STATUS_UNCLASSIFIED = 'unclassified';
+    public const STATUS_PENDING      = 'pending';
+    public const STATUS_APPROVED     = 'approved';
+
+    public static function statusLabels(): array
+    {
+        return [
+            self::STATUS_NOT_NEEDED   => 'Не нужна',
+            self::STATUS_UNCLASSIFIED => 'Не размечен',
+            self::STATUS_PENDING      => 'Подтвердить',
+            self::STATUS_APPROVED     => 'Подтверждён',
+        ];
+    }
+
+    public function behaviors(): array
+    {
+        return array_merge(parent::behaviors(), [
+            'access' => [
+                'class' => AccessControl::class,
+                'rules' => [
+                    [
+                        'allow'       => true,
+                        'actions'     => ['ajax-approve', 'ajax-bulk-approve', 'ajax-mark-not-needed', 'ajax-save-markup', 'ajax-revert-markup'],
+                        'permissions' => ['catmanager/manage'],
+                    ],
+                    [
+                        'allow'       => true,
+                        'actions'     => ['index', 'ajax-product-data'],
+                        'permissions' => ['catmanager/view'],
+                    ],
+                ],
+            ],
+            'verbs' => [
+                'class'   => VerbFilter::class,
+                'actions' => [
+                    'ajax-approve'         => ['POST'],
+                    'ajax-bulk-approve'    => ['POST'],
+                    'ajax-mark-not-needed' => ['POST'],
+                    'ajax-save-markup'     => ['POST'],
+                    'ajax-revert-markup'   => ['POST'],
+                ],
+            ],
+        ]);
+    }
+
+    public function actionIndex(): string
+    {
+        $filter = new DynamicModel([
+            'category', 'species', 'classification_status', 'confidence_range', 'pageSize',
+        ]);
+        $filter->addRule(['category', 'species', 'classification_status', 'confidence_range'], 'safe');
+        $filter->addRule(['pageSize'], 'integer');
+        $filter->load(Yii::$app->request->get());
+
+        $pageSize = in_array((int)$filter->pageSize, [50, 100, 500]) ? (int)$filter->pageSize : 50;
+
+        // Base query with category/species filters (used for counter cards too)
+        $baseQuery = Products1cNomenclature::find();
+        if ($filter->category) {
+            $baseQuery->andWhere(['category' => $filter->category]);
+        }
+        if ($filter->species) {
+            $baseQuery->andWhere(['species' => $filter->species]);
+        }
+
+        // Counter cards (no status/confidence filter — show all statuses)
+        $counters = array_fill_keys([
+            self::STATUS_NOT_NEEDED, self::STATUS_UNCLASSIFIED,
+            self::STATUS_PENDING, self::STATUS_APPROVED,
+        ], 0);
+        $counts = (clone $baseQuery)
+            ->select(['classification_status', 'cnt' => 'COUNT(*)'])
+            ->groupBy('classification_status')
+            ->asArray()
+            ->all();
+        foreach ($counts as $row) {
+            $key = $row['classification_status'] ?? self::STATUS_UNCLASSIFIED;
+            if (array_key_exists($key, $counters)) {
+                $counters[$key] = (int)$row['cnt'];
+            }
+        }
+
+        // Full query with all filters
+        $query = clone $baseQuery;
+        if ($filter->classification_status) {
+            $query->andWhere(['classification_status' => $filter->classification_status]);
+        }
+        if ($filter->confidence_range) {
+            match ($filter->confidence_range) {
+                'high'   => $query->andWhere(['>=', 'confidence', 90]),
+                'medium' => $query->andWhere(['between', 'confidence', 70, 89]),
+                'low'    => $query->andWhere(['<', 'confidence', 70]),
+                default  => null,
+            };
+        }
+        $query->orderBy(['name' => SORT_ASC]);
+
+        $dataProvider = new ActiveDataProvider([
+            'query'      => $query,
+            'pagination' => ['pageSize' => $pageSize],
+        ]);
+
+        $categories  = array_filter(
+            Products1cNomenclature::find()->select('category')->distinct()->orderBy('category')->column()
+        );
+        $speciesList = array_filter(
+            Products1cNomenclature::find()->select('species')->distinct()
+                ->andWhere(['not', ['species' => null]])->orderBy('species')->column()
+        );
+        $sorts = array_filter(
+            Products1cNomenclature::find()->select('sort')->distinct()
+                ->andWhere(['not', ['sort' => null]])->orderBy('sort')->column()
+        );
+        $colors = array_filter(
+            Products1cNomenclature::find()->select('color')->distinct()
+                ->andWhere(['not', ['color' => null]])->orderBy('color')->column()
+        );
+
+        return $this->render('index', [
+            'filter'       => $filter,
+            'dataProvider' => $dataProvider,
+            'counters'     => $counters,
+            'categories'   => array_combine($categories, $categories),
+            'speciesList'  => array_combine($speciesList, $speciesList),
+            'sorts'        => array_values($sorts),
+            'colors'       => array_values($colors),
+            'pageSize'     => $pageSize,
+        ]);
+    }
+
+    public function actionAjaxApprove(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid = Yii::$app->request->post('guid');
+        if (!$guid) {
+            return ['success' => false, 'message' => 'Missing guid'];
+        }
+
+        $product = $this->findProduct($guid);
+
+        if ($product->classification_status === self::STATUS_UNCLASSIFIED) {
+            return ['success' => false, 'message' => 'Неразмеченный товар нельзя подтвердить напрямую. Используйте ✎ редактирование.'];
+        }
+
+        return $this->applyStatus($product, self::STATUS_APPROVED);
+    }
+
+    public function actionAjaxBulkApprove(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guids = array_values(array_filter((array)Yii::$app->request->post('guids', [])));
+        if (empty($guids)) {
+            return ['success' => false, 'message' => 'Не выбраны товары'];
+        }
+
+        $now    = date('Y-m-d H:i:s');
+        $userId = (int)Yii::$app->user->id;
+
+        $updated = Products1cNomenclature::updateAll(
+            [
+                'classification_status' => self::STATUS_APPROVED,
+                'classified_by'         => $userId,
+                'classified_at'         => $now,
+            ],
+            ['id' => $guids, 'classification_status' => self::STATUS_PENDING]
+        );
+
+        return [
+            'success' => true,
+            'message' => "Подтверждено: {$updated} из " . count($guids) . ' (только pending)',
+            'updated' => $updated,
+        ];
+    }
+
+    public function actionAjaxMarkNotNeeded(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid = Yii::$app->request->post('guid');
+        if (!$guid) {
+            return ['success' => false, 'message' => 'Missing guid'];
+        }
+
+        return $this->applyStatus($this->findProduct($guid), self::STATUS_NOT_NEEDED);
+    }
+
+    public function actionAjaxRevertMarkup(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid   = Yii::$app->request->post('guid');
+        $reason = trim(Yii::$app->request->post('reason', ''));
+
+        if (!$guid) {
+            return ['success' => false, 'message' => 'Missing guid'];
+        }
+        if ($reason === '') {
+            return ['success' => false, 'message' => 'Причина обязательна'];
+        }
+
+        $product = $this->findProduct($guid);
+
+        if ($product->classification_status !== self::STATUS_NOT_NEEDED) {
+            return ['success' => false, 'message' => 'Статус товара не not_needed'];
+        }
+
+        AuditLog::write(
+            Products1cNomenclature::tableName(),
+            $product->id,
+            AuditLog::ACTION_UPDATE,
+            ['classification_status' => self::STATUS_NOT_NEEDED],
+            ['classification_status' => self::STATUS_UNCLASSIFIED, 'reason' => $reason],
+        );
+
+        $product->classification_status = self::STATUS_UNCLASSIFIED;
+        $product->classified_by         = (int)Yii::$app->user->id;
+        $product->classified_at         = date('Y-m-d H:i:s');
+        $product->confidence            = null;
+
+        if (!$product->save(false)) {
+            return ['success' => false, 'message' => 'Ошибка сохранения'];
+        }
+
+        return ['success' => true, 'status' => self::STATUS_UNCLASSIFIED];
+    }
+
+    public function actionAjaxProductData(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $guid = Yii::$app->request->get('guid');
+        if (!$guid) {
+            throw new BadRequestHttpException('Missing guid');
+        }
+
+        $p = $this->findProduct($guid);
+
+        return [
+            'success'               => true,
+            'guid'                  => $p->id,
+            'name'                  => $p->name,
+            'category'              => $p->category,
+            'species'               => $p->species,
+            'sort'                  => $p->sort,
+            'color'                 => $p->color,
+            'size'                  => $p->size,
+            'classification_status' => $p->classification_status,
+            'confidence'            => $p->confidence,
+        ];
+    }
+
+    public function actionAjaxSaveMarkup(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post = Yii::$app->request->post();
+        $guid = $post['guid'] ?? null;
+        if (!$guid) {
+            return ['success' => false, 'message' => 'Missing guid'];
+        }
+
+        $product = $this->findProduct($guid);
+
+        $category = trim($post['category'] ?? '');
+        if (!$category) {
+            return ['success' => false, 'message' => 'Категория обязательна'];
+        }
+
+        $product->category              = $category;
+        $product->species               = trim($post['species'] ?? '') ?: null;
+        $product->sort                  = trim($post['sort'] ?? '') ?: null;
+        $product->color                 = trim($post['color'] ?? '') ?: null;
+        $product->size                  = isset($post['size']) && $post['size'] !== '' ? (int)$post['size'] : null;
+        $product->classification_status = self::STATUS_APPROVED;
+        $product->classified_by         = (int)Yii::$app->user->id;
+        $product->classified_at         = date('Y-m-d H:i:s');
+        $product->confidence            = null;
+
+        if (!$product->save(false)) {
+            return ['success' => false, 'message' => 'Ошибка сохранения'];
+        }
+
+        return [
+            'success' => true,
+            'message' => 'Сохранено и подтверждено',
+            'product' => [
+                'guid'                  => $product->id,
+                'category'              => $product->category,
+                'species'               => $product->species,
+                'sort'                  => $product->sort,
+                'color'                 => $product->color,
+                'size'                  => $product->size,
+                'classification_status' => $product->classification_status,
+            ],
+        ];
+    }
+
+    private function applyStatus(Products1cNomenclature $product, string $status): array
+    {
+        $product->classification_status = $status;
+        $product->classified_by         = (int)Yii::$app->user->id;
+        $product->classified_at         = date('Y-m-d H:i:s');
+
+        if (!$product->save(false)) {
+            return ['success' => false, 'message' => 'Ошибка сохранения'];
+        }
+
+        return ['success' => true, 'status' => $status];
+    }
+
+    private function findProduct(string $guid): Products1cNomenclature
+    {
+        $model = Products1cNomenclature::findOne($guid);
+        if ($model === null) {
+            throw new NotFoundHttpException('Товар не найден');
+        }
+        return $model;
+    }
+}
diff --git a/erp24/docs/erp325_catmanager_presentation.html b/erp24/docs/erp325_catmanager_presentation.html
new file mode 100644 (file)
index 0000000..1f2246f
--- /dev/null
@@ -0,0 +1,677 @@
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>ERP-325 — Каталог категорийного менеджера</title>
+<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
+<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.0/dist/mermaid.min.js"></script>
+<style>
+:root{--violet:#7c3aed;--violet-light:#f3e8ff;--violet-border:#d8b4fe;}
+body{font-family:'Segoe UI',system-ui,sans-serif;background:#f8fafc;}
+.hero{background:linear-gradient(135deg,#1e3a5f 0%,#7c3aed 100%);color:#fff;padding:60px 0 40px;}
+.hero h1{font-size:2.4rem;font-weight:800;}
+.hero .badge-task{background:rgba(255,255,255,.18);color:#fff;font-size:.75rem;padding:4px 10px;border-radius:20px;margin:2px;}
+.section-title{font-size:1.5rem;font-weight:700;border-left:4px solid var(--violet);padding-left:12px;margin-bottom:1.5rem;}
+.card-feature{border:none;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);height:100%;}
+.card-feature .card-header{border-radius:12px 12px 0 0;font-weight:700;}
+.task-badge{display:inline-block;font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:12px;background:#e0e7ff;color:#3730a3;margin-bottom:8px;}
+.must-badge{background:#fee2e2;color:#991b1b;}
+.should-badge{background:#fef3c7;color:#92400e;}
+.file-list{font-size:.8rem;font-family:'Courier New',monospace;background:#f1f5f9;border-radius:6px;padding:10px 14px;}
+.file-list li{padding:2px 0;color:#374151;}
+.file-list .new{color:#059669;}
+.file-list .modified{color:#d97706;}
+.concept-chip{display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;font-weight:500;background:var(--violet-light);color:var(--violet);border:1px dashed var(--violet-border);margin:2px;}
+.label-chip-demo{display:inline-block;font-size:11px;padding:3px 9px;border-radius:12px;font-weight:600;margin:2px;color:#fff;}
+.status-demo{display:inline-block;font-size:11px;padding:3px 10px;border-radius:12px;font-weight:600;margin:2px;}
+.mermaid-wrap{background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 12px rgba(0,0,0,.07);margin-bottom:1.5rem;overflow:auto;}
+.perm-table th{background:#1e3a5f;color:#fff;}
+.row-not-needed{background:#f8f9fa;opacity:.8;}
+.row-unclassified{background:#fff5f5;}
+.row-pending-mid{background:#fffbeb;}
+.row-pending-ok,.row-approved{background:#fff;}
+.deploy-box{background:#1e3a5f;color:#e2e8f0;border-radius:10px;padding:20px 24px;font-family:'Courier New',monospace;font-size:.85rem;}
+.deploy-box .cmd{color:#86efac;}
+.deploy-box .comment{color:#94a3b8;}
+nav.sticky-top{background:#1e3a5f!important;}
+nav .nav-link{color:rgba(255,255,255,.75)!important;font-size:.85rem;}
+nav .nav-link:hover{color:#fff!important;}
+.toc-link{display:block;padding:3px 0;font-size:.85rem;color:#4b5563;text-decoration:none;}
+.toc-link:hover{color:var(--violet);}
+</style>
+</head>
+<body>
+
+<!-- HERO -->
+<div class="hero">
+  <div class="container">
+    <div class="row align-items-center">
+      <div class="col-lg-8">
+        <div class="mb-2">
+          <span class="badge-task">ERP-325</span>
+          <span class="badge-task">feature_fomichev_ERP-325J</span>
+          <span class="badge-task">2026-04-22</span>
+        </div>
+        <h1 class="mb-2">Каталог категорийного менеджера</h1>
+        <p class="lead opacity-75 mb-3">Полная реализация модуля CM: разметка номенклатуры, bulk-операции, RBAC, концепции из 1С.</p>
+        <div>
+          <span class="badge bg-success me-1">ERP-332 ✓</span>
+          <span class="badge bg-success me-1">ERP-333 ✓</span>
+          <span class="badge bg-success me-1">ERP-337 ✓</span>
+          <span class="badge bg-success me-1">ERP-334 ✓</span>
+          <span class="badge bg-success me-1">ERP-330 ✓</span>
+        </div>
+      </div>
+      <div class="col-lg-4 mt-4 mt-lg-0">
+        <div class="bg-white bg-opacity-10 rounded-3 p-3">
+          <div class="small text-white-50 mb-2">Статистика</div>
+          <div class="row g-2 text-center">
+            <div class="col-4"><div class="fs-4 fw-bold">5</div><div class="small opacity-75">задач</div></div>
+            <div class="col-4"><div class="fs-4 fw-bold">3</div><div class="small opacity-75">миграции</div></div>
+            <div class="col-4"><div class="fs-4 fw-bold">12</div><div class="small opacity-75">файлов</div></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- NAV -->
+<nav class="navbar navbar-dark sticky-top py-1" style="background:#1e3a5f!important;">
+  <div class="container">
+    <span class="navbar-brand fs-6 fw-bold">ERP-325</span>
+    <div class="d-flex gap-3">
+      <a href="#arch" class="nav-link">Архитектура</a>
+      <a href="#erp332" class="nav-link">ERP-332</a>
+      <a href="#erp333" class="nav-link">ERP-333</a>
+      <a href="#erp337" class="nav-link">RBAC</a>
+      <a href="#erp334" class="nav-link">ERP-334</a>
+      <a href="#erp330" class="nav-link">ERP-330</a>
+      <a href="#db" class="nav-link">БД</a>
+      <a href="#deploy" class="nav-link">Деплой</a>
+    </div>
+  </div>
+</nav>
+
+<div class="container py-5">
+
+<!-- OVERVIEW CARDS -->
+<div class="row g-3 mb-5">
+  <div class="col-md-4">
+    <div class="card card-feature">
+      <div class="card-header bg-primary text-white">Вкладка: Актуальность</div>
+      <div class="card-body">
+        <p class="small text-muted">Существующая страница — расширена bulk-операциями с лейблами и отображением концепций из 1С.</p>
+        <div class="file-list"><ul class="mb-0 ps-3">
+          <li class="modified">Products1cNomenclatureActualityController</li>
+          <li class="modified">views/products1c-nomenclature-actuality/index.php</li>
+          <li class="modified">web/js/…Actuality/index.js</li>
+        </ul></div>
+      </div>
+    </div>
+  </div>
+  <div class="col-md-4">
+    <div class="card card-feature">
+      <div class="card-header text-white" style="background:var(--violet)">Вкладка: Разметка</div>
+      <div class="card-body">
+        <p class="small text-muted">Новая страница — workflow классификации товаров с 4 статусами, фильтрами, bulk и edit-модалкой.</p>
+        <div class="file-list"><ul class="mb-0 ps-3">
+          <li class="new">Products1cNomenclatureMarkupController</li>
+          <li class="new">views/products1c-nomenclature-markup/index.php</li>
+          <li class="new">web/js/…Markup/index.js</li>
+        </ul></div>
+      </div>
+    </div>
+  </div>
+  <div class="col-md-4">
+    <div class="card card-feature">
+      <div class="card-header bg-success text-white">Вкладка: Матрица</div>
+      <div class="card-body">
+        <p class="small text-muted">Существующая страница лейблов — добавлен AccessControl.</p>
+        <div class="file-list"><ul class="mb-0 ps-3">
+          <li class="modified">AssortmentLabelController</li>
+        </ul></div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- ARCHITECTURE -->
+<h2 class="section-title" id="arch">Архитектура модуля</h2>
+<div class="mermaid-wrap">
+<div class="mermaid">
+graph TB
+    subgraph Browser["🌐 Браузер"]
+        TabA["Вкладка: Актуальность"]
+        TabB["Вкладка: Разметка"]
+        TabC["Вкладка: Матрица"]
+    end
+
+    subgraph Controllers["⚙️ Контроллеры (app\\controllers)"]
+        CA["Products1cNomenclatureActualityController<br/>actionIndex / ajaxIntervals / ajaxLabels<br/>ajaxScores / ajaxBulkAssign / ..."]
+        CM["Products1cNomenclatureMarkupController<br/>actionIndex / ajaxApprove / ajaxBulkApprove<br/>ajaxMarkNotNeeded / ajaxSaveMarkup<br/>ajaxRevertMarkup"]
+        CL["AssortmentLabelController<br/>actionIndex / ajaxList<br/>ajaxSave / ajaxToggleActive / ajaxDelete"]
+    end
+
+    subgraph Records["📦 AR-Модели (yii_app\\records)"]
+        N["Products1cNomenclature<br/>products_1c_nomenclature"]
+        PA["ProductAssortment<br/>product_assortment"]
+        AL["AssortmentLabel<br/>assortment_labels"]
+        PC["ProductConcept<br/>product_concepts"]
+        AUD["AuditLog<br/>audit_log"]
+    end
+
+    subgraph Infra["🔧 Инфраструктура"]
+        RBAC["Yii2 RBAC<br/>DbManager<br/>catmanager/view<br/>catmanager/manage"]
+        CRON["Cron<br/>products1c-concept/sync"]
+        SOURCE["bouquet_components<br/>(1С-интеграция, Phase 2)"]
+    end
+
+    TabA --> CA
+    TabB --> CM
+    TabC --> CL
+
+    CA --> N
+    CA --> PA
+    CA --> AL
+    CA --> PC
+    CM --> N
+    CM --> AUD
+    CL --> AL
+    CL --> PA
+
+    RBAC -->|"AccessControl"| CA
+    RBAC -->|"AccessControl"| CM
+    RBAC -->|"AccessControl"| CL
+
+    CRON -->|"TRUNCATE + INSERT"| PC
+    SOURCE -.->|"если существует"| CRON
+
+    style CM fill:#f3e8ff,stroke:#7c3aed
+    style CM color:#4c1d95
+    style PC fill:#f3e8ff,stroke:#7c3aed
+    style RBAC fill:#fef9c3,stroke:#ca8a04
+</div>
+</div>
+
+<!-- STATE DIAGRAM -->
+<h2 class="section-title mt-5" id="erp333">ERP-333 CM-2.1 — Вкладка «Разметка»</h2>
+<div class="row g-4 mb-4">
+  <div class="col-lg-6">
+    <h5 class="fw-semibold">Статусная машина классификации</h5>
+    <div class="mermaid-wrap">
+<div class="mermaid">
+stateDiagram-v2
+    [*] --> unclassified : новый товар из 1С
+    unclassified --> pending : ML-классификация
+    unclassified --> not_needed : КМ нажимает ⊘
+    pending --> approved : КМ нажимает ✓<br/>или ✎ Save
+    pending --> not_needed : КМ нажимает ⊘
+    pending --> approved : Bulk-подтверждение
+    not_needed --> unclassified : КМ нажимает ↩<br/>(ERP-334, с причиной)
+    approved --> approved : КМ редактирует ✎
+
+    note right of not_needed
+        Audit log записывается
+        при реверсе (ERP-334)
+    end note
+</div>
+    </div>
+  </div>
+  <div class="col-lg-6">
+    <h5 class="fw-semibold">Раскраска строк по статусу / confidence</h5>
+    <table class="table table-bordered table-sm">
+      <thead><tr><th>Статус</th><th>Confidence</th><th>Фон строки</th><th>Кнопки</th></tr></thead>
+      <tbody>
+        <tr class="row-not-needed"><td><span class="status-demo bg-secondary">Не нужна</span></td><td>—</td><td>#f8f9fa, opacity .75</td><td>✎ ↩</td></tr>
+        <tr class="row-unclassified"><td><span class="status-demo bg-danger">Не размечен</span></td><td>—</td><td>#fff5f5</td><td>✎ ⊘</td></tr>
+        <tr class="row-pending-ok"><td><span class="status-demo bg-warning text-dark">Подтвердить</span></td><td>≥ 90%</td><td>белый</td><td>✓ ✎ ⊘</td></tr>
+        <tr class="row-pending-mid"><td><span class="status-demo bg-warning text-dark">Подтвердить</span></td><td>70–89%</td><td>#fffbeb</td><td>✓ ✎ ⊘</td></tr>
+        <tr class="row-unclassified"><td><span class="status-demo bg-warning text-dark">Подтвердить</span></td><td>&lt; 70%</td><td>#fff5f5</td><td>✓ ✎ ⊘</td></tr>
+        <tr class="row-approved"><td><span class="status-demo bg-success">Подтверждён</span></td><td>—</td><td>белый</td><td>✎</td></tr>
+      </tbody>
+    </table>
+    <h5 class="fw-semibold mt-3">Действия (sequence)</h5>
+    <div class="mermaid-wrap p-2">
+<div class="mermaid">
+sequenceDiagram
+    actor КМ
+    participant JS as index.js
+    participant Ctrl as MarkupController
+    participant DB as products_1c_nomenclature
+
+    КМ->>JS: click ✓ (approve btn)
+    JS->>Ctrl: POST /ajax-approve {guid}
+    Ctrl->>DB: $product->save()
+    DB-->>Ctrl: ok
+    Ctrl-->>JS: {success:true, status:'approved'}
+    JS->>JS: applyRowStatus($row, 'approved')
+    JS-->>КМ: Toast «Подтверждено»
+</div>
+    </div>
+  </div>
+</div>
+
+<!-- EDIT MODAL -->
+<div class="card mb-5 border-0 shadow-sm">
+  <div class="card-header fw-bold" style="background:#f3e8ff;color:#4c1d95">Edit-модалка (Variant B — полное редактирование)</div>
+  <div class="card-body">
+    <div class="row">
+      <div class="col-md-5">
+        <p class="small text-muted">Открывается по ✎, поля заполняются из <code>data-*</code> атрибутов кнопки (без доп. AJAX-запроса). Списки категорий/видов/сортов/цветов предзагружены в <code>window.markupConfig</code>.</p>
+        <ul class="small">
+          <li><strong>Категория</strong> — обязательная, select</li>
+          <li><strong>Вид</strong> — опционально, select</li>
+          <li><strong>Сорт</strong> — опционально, select</li>
+          <li><strong>Цвет</strong> — опционально, select</li>
+          <li><strong>Высота (см)</strong> — число, input[type=number]</li>
+        </ul>
+        <p class="small text-muted">При сохранении: статус → <code>approved</code>, <code>confidence=null</code>, <code>classified_by/at</code> — текущий пользователь.</p>
+      </div>
+      <div class="col-md-7">
+        <div class="mermaid-wrap p-2">
+<div class="mermaid">
+sequenceDiagram
+    actor КМ
+    participant JS as index.js
+    participant Ctrl as MarkupController
+    participant DB as DB
+
+    КМ->>JS: click ✎ (edit btn)
+    JS->>JS: populateSelect() из window.markupConfig
+    JS->>JS: Modal.show()
+    КМ->>JS: заполняет поля, click «Сохранить»
+    JS->>JS: валидация (category required)
+    JS->>Ctrl: POST /ajax-save-markup
+    Ctrl->>DB: $product->save(false)
+    DB-->>Ctrl: ok
+    Ctrl-->>JS: {success, product:{...}}
+    JS->>JS: updateRowFields() + applyRowStatus()
+    JS->>JS: Modal.hide()
+    JS-->>КМ: Toast «Сохранено и подтверждено»
+</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- ERP-332 -->
+<h2 class="section-title" id="erp332">ERP-332 CM-1.6 — Bulk-операции с лейблами</h2>
+<div class="mermaid-wrap">
+<div class="mermaid">
+sequenceDiagram
+    actor КМ
+    participant JS as index.js (Actuality)
+    participant LabelCtrl as AssortmentLabelController
+    participant ActCtrl as ActualityController
+    participant DB
+
+    КМ->>JS: отмечает чекбоксы (3 товара)
+    JS->>JS: selectedGuids.add(guid), updateBulkToolbar()
+    Note over JS: toolbar становится visible, счётчик «3 выбрано»
+
+    КМ->>JS: открывает dropdown лейблов (first time)
+    JS->>LabelCtrl: GET /assortment-label/ajax-list
+    LabelCtrl->>DB: AssortmentLabel::find()->where(['is_active'=>true])
+    DB-->>LabelCtrl: [{id, name, color}, ...]
+    LabelCtrl-->>JS: JSON
+    JS->>JS: populate #bulkLabelSelect
+
+    КМ->>JS: выбирает лейбл, click «Добавить»
+    JS->>ActCtrl: POST /ajax-bulk-assign {guids[], label_ids[], action:'add'}
+    loop для каждого guid
+        ActCtrl->>DB: INSERT IGNORE INTO product_assortment
+    end
+    DB-->>ActCtrl: updated_count
+    ActCtrl-->>JS: {success, results:{guid:[{id,name,color}]}}
+    JS->>JS: updateRowLabels() для каждого guid
+    JS-->>КМ: Toast «Добавлено»
+</div>
+</div>
+
+<!-- RBAC -->
+<h2 class="section-title mt-5" id="erp337">ERP-337 CM-4.2 — RBAC</h2>
+<div class="row g-4 mb-4">
+  <div class="col-lg-7">
+    <div class="mermaid-wrap">
+<div class="mermaid">
+graph LR
+    subgraph Roles["Роли (group_id)"]
+        KM["КМ<br/>group_id=82<br/>GROUP_CATMANAGER"]
+        BUY["Закупщик<br/>group_id=?<br/>(настраивается)"]
+        DIR["Руководитель<br/>group_id=?<br/>(настраивается)"]
+    end
+
+    subgraph Perms["Разрешения"]
+        MNG["catmanager/manage<br/>Полный CRUD"]
+        VIEW["catmanager/view<br/>Read-only"]
+    end
+
+    subgraph Actions["Действия контроллеров"]
+        WRITE["✓ approve<br/>⊘ not-needed<br/>✎ save-markup<br/>↩ revert<br/>bulk-approve<br/>bulk-assign<br/>ajax-save/delete"]
+        READ["index<br/>ajax-list<br/>ajax-product-data<br/>ajax-intervals<br/>ajax-labels"]
+    end
+
+    KM --> MNG
+    KM --> VIEW
+    BUY --> VIEW
+    DIR --> VIEW
+
+    MNG --> WRITE
+    VIEW --> READ
+
+    style KM fill:#f3e8ff,stroke:#7c3aed
+    style MNG fill:#fee2e2,stroke:#ef4444
+    style VIEW fill:#dcfce7,stroke:#22c55e
+</div>
+    </div>
+  </div>
+  <div class="col-lg-5">
+    <h5 class="fw-semibold">Матрица доступа</h5>
+    <table class="table table-bordered table-sm perm-table">
+      <thead>
+        <tr><th>Действие</th><th>КМ</th><th>Закупщик</th><th>Руководитель</th></tr>
+      </thead>
+      <tbody>
+        <tr><td>Просмотр страниц</td><td class="text-center text-success">✓</td><td class="text-center text-success">✓</td><td class="text-center text-success">✓</td></tr>
+        <tr><td>Approve markup</td><td class="text-center text-success">✓</td><td class="text-center text-danger">✗</td><td class="text-center text-danger">✗</td></tr>
+        <tr><td>Edit markup</td><td class="text-center text-success">✓</td><td class="text-center text-danger">✗</td><td class="text-center text-danger">✗</td></tr>
+        <tr><td>Not needed / Revert</td><td class="text-center text-success">✓</td><td class="text-center text-danger">✗</td><td class="text-center text-danger">✗</td></tr>
+        <tr><td>Bulk approve</td><td class="text-center text-success">✓</td><td class="text-center text-danger">✗</td><td class="text-center text-danger">✗</td></tr>
+        <tr><td>Bulk label assign</td><td class="text-center text-success">✓</td><td class="text-center text-danger">✗</td><td class="text-center text-danger">✗</td></tr>
+        <tr><td>CRUD лейблов</td><td class="text-center text-success">✓</td><td class="text-center text-danger">✗</td><td class="text-center text-danger">✗</td></tr>
+      </tbody>
+    </table>
+    <div class="alert alert-warning small mt-2 mb-0">
+      <strong>После деплоя:</strong> запустить <code>php yii auth/init</code>. Закупщик/Руководитель — настроить через <code>/auth/show-groups</code>.
+    </div>
+  </div>
+</div>
+
+<!-- ERP-334 -->
+<h2 class="section-title" id="erp334">ERP-334 CM-2.2 — Реверс not_needed</h2>
+<div class="row g-4 mb-5">
+  <div class="col-lg-6">
+    <div class="mermaid-wrap">
+<div class="mermaid">
+sequenceDiagram
+    actor КМ
+    participant JS as index.js
+    participant Ctrl as MarkupController
+    participant N as products_1c_nomenclature
+    participant AUD as audit_log
+
+    КМ->>JS: click ↩ (revert btn, статус=not_needed)
+    JS->>JS: Modal #markupRevertModal.show()
+    КМ->>JS: вводит причину, click «Вернуть»
+    JS->>JS: валидация: reason.trim() !== ''
+    alt причина пуста
+        JS-->>КМ: is-invalid highlight
+    else причина введена
+        JS->>Ctrl: POST /ajax-revert-markup {guid, reason}
+        Ctrl->>Ctrl: проверить статус === 'not_needed'
+        Ctrl->>AUD: AuditLog::write(old:{not_needed}, new:{unclassified, reason})
+        Ctrl->>N: classification_status='unclassified', confidence=null
+        N-->>Ctrl: saved
+        Ctrl-->>JS: {success:true, status:'unclassified'}
+        JS->>JS: applyRowStatus($row, 'unclassified')
+        JS->>JS: Modal.hide()
+        JS-->>КМ: Toast «Возвращено в разметку»
+    end
+</div>
+    </div>
+  </div>
+  <div class="col-lg-6">
+    <h5 class="fw-semibold">Запись в audit_log</h5>
+    <table class="table table-sm table-bordered">
+      <tr><td class="fw-semibold">entity_type</td><td><code>products_1c_nomenclature</code></td></tr>
+      <tr><td class="fw-semibold">entity_id</td><td>GUID товара</td></tr>
+      <tr><td class="fw-semibold">action</td><td><code>update</code></td></tr>
+      <tr><td class="fw-semibold">old_values</td><td><code>{"classification_status":"not_needed"}</code></td></tr>
+      <tr><td class="fw-semibold">new_values</td><td><code>{"classification_status":"unclassified","reason":"..."}</code></td></tr>
+      <tr><td class="fw-semibold">user_id</td><td>ID текущего КМ</td></tr>
+      <tr><td class="fw-semibold">user_role</td><td>Имя группы из admin_group</td></tr>
+    </table>
+    <div class="mt-3">
+      <h5 class="fw-semibold">Доступность кнопок по статусу</h5>
+      <table class="table table-sm table-bordered">
+        <thead><tr><th>Статус</th><th>✓</th><th>✎</th><th>⊘</th><th>↩</th></tr></thead>
+        <tbody>
+          <tr><td>not_needed</td><td>—</td><td>✓</td><td>—</td><td>✓</td></tr>
+          <tr><td>unclassified</td><td>—</td><td>✓</td><td>✓</td><td>—</td></tr>
+          <tr><td>pending</td><td>✓</td><td>✓</td><td>✓</td><td>—</td></tr>
+          <tr><td>approved</td><td>—</td><td>✓</td><td>—</td><td>—</td></tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
+</div>
+
+<!-- ERP-330 -->
+<h2 class="section-title" id="erp330">ERP-330 CM-1.4 — Концепции из 1С</h2>
+<div class="row g-4 mb-5">
+  <div class="col-lg-7">
+    <div class="mermaid-wrap">
+<div class="mermaid">
+sequenceDiagram
+    participant CRON as Cron (ежедневно 03:00)
+    participant CMD as Products1cConceptController
+    participant SRC as bouquet_components<br/>(1С-интеграция)
+    participant TGT as product_concepts
+
+    CRON->>CMD: php yii products1c-concept/sync
+    CMD->>CMD: getTableSchema('bouquet_components')
+
+    alt таблица существует
+        CMD->>TGT: TRUNCATE product_concepts
+        CMD->>SRC: SELECT DISTINCT product_guid, concept_name
+        SRC-->>CMD: rows
+        CMD->>TGT: INSERT INTO product_concepts (bulk)
+        CMD-->>CRON: «Синхронизировано: N концепций», ExitCode::OK
+    else таблица отсутствует (Phase 2)
+        CMD-->>CRON: warning log, «Пропускаем», ExitCode::OK
+    end
+</div>
+    </div>
+    <div class="mermaid-wrap mt-3">
+<div class="mermaid">
+graph LR
+    subgraph Actuality["Страница Актуальности"]
+        ROW["Строка товара"]
+        LABELS["Лейблы-чипы<br/>(цветные, removable)"]
+        CONCEPTS["Концепции-чипы<br/>(фиолетовые dashed, read-only)"]
+    end
+    ROW --> LABELS
+    ROW --> CONCEPTS
+
+    subgraph Data["Источники данных"]
+        PA["product_assortment<br/>+ assortment_labels"]
+        PC["product_concepts"]
+    end
+
+    PA --> LABELS
+    PC --> CONCEPTS
+
+    style CONCEPTS fill:#f3e8ff,stroke:#7c3aed
+    style PC fill:#f3e8ff,stroke:#7c3aed
+</div>
+    </div>
+  </div>
+  <div class="col-lg-5">
+    <h5 class="fw-semibold">Внешний вид концепций</h5>
+    <div class="p-3 border rounded mb-3">
+      <div class="small text-muted mb-2">Пример: лейблы + концепции в строке</div>
+      <div>
+        <span class="label-chip-demo" style="background:#4CAF50">Топ-ассортимент</span>
+        <span class="label-chip-demo" style="background:#2196F3">Розница</span>
+      </div>
+      <div class="mt-1">
+        <span class="concept-chip"><i>🎨</i> Классика</span>
+        <span class="concept-chip"><i>🎨</i> Премиум</span>
+        <span class="concept-chip"><i>🎨</i> Моно</span>
+      </div>
+    </div>
+    <h5 class="fw-semibold">CSS концепций</h5>
+    <pre class="bg-light p-2 rounded small">.concept-chip {
+  background: #f3e8ff;
+  color: #7c3aed;
+  border: 1px dashed #d8b4fe;
+  font-size: 10px;
+  padding: 2px 8px;
+  border-radius: 4px;
+}</pre>
+    <div class="alert alert-info small mt-3">
+      <strong>Graceful fallback:</strong> если таблица <code>product_concepts</code> не создана (до первой миграции) — контроллер проверяет её существование через <code>getTableSchema()</code> и не падает с ошибкой.
+    </div>
+  </div>
+</div>
+
+<!-- DB SCHEMA -->
+<h2 class="section-title" id="db">Схема данных</h2>
+<div class="mermaid-wrap">
+<div class="mermaid">
+erDiagram
+    products_1c_nomenclature {
+        string id PK "GUID"
+        string name
+        string category
+        string species
+        string sort
+        string color
+        int size
+        string classification_status "not_needed|unclassified|pending|approved"
+        int confidence "0-100, ML"
+        int classified_by
+        datetime classified_at
+    }
+
+    assortment_labels {
+        int id PK
+        string name
+        string color
+        string channel_type
+        boolean is_active
+        datetime created_at
+        int created_by
+    }
+
+    product_assortment {
+        int id PK
+        string product_guid FK
+        int label_id FK
+        datetime created_at
+        int created_by
+    }
+
+    product_concepts {
+        int id PK
+        string product_guid FK
+        string concept_name
+        datetime synced_at
+    }
+
+    audit_log {
+        bigint id PK
+        string entity_type
+        string entity_id
+        string action
+        jsonb old_values
+        jsonb new_values
+        int user_id
+        string user_role
+        datetime created_at
+    }
+
+    admin_group_rbac_config {
+        int id PK
+        int admin_group_id
+        text config "CSV: catmanager/view,catmanager/manage"
+    }
+
+    products_1c_nomenclature ||--o{ product_assortment : "has"
+    assortment_labels ||--o{ product_assortment : "assigned to"
+    products_1c_nomenclature ||--o{ product_concepts : "belongs to"
+    products_1c_nomenclature ||--o{ audit_log : "logged"
+</div>
+</div>
+
+<!-- DEPLOY -->
+<h2 class="section-title mt-5" id="deploy">Порядок деплоя</h2>
+<div class="deploy-box">
+<pre class="mb-0"><span class="comment"># 1. Применить миграции</span>
+<span class="cmd">php yii migrate</span>
+<span class="comment"># Применяет:
+#   m260422_100000_add_classification_fields_to_nomenclature
+#   m260422_110000_add_catmanager_rbac
+#   m260422_120000_create_product_concepts_table</span>
+
+<span class="comment"># 2. Инициализировать RBAC (пересоздаёт permission-таблицы)</span>
+<span class="cmd">php yii auth/init</span>
+
+<span class="comment"># 3. Добавить cron-задачу (раз в сутки, 03:00)</span>
+<span class="cmd">crontab -e</span>
+<span class="comment"># Добавить строку:</span>
+<span class="cmd">0 3 * * * php /var/www/erp24/yii products1c-concept/sync >> /var/log/concept-sync.log 2>&amp;1</span>
+
+<span class="comment"># 4. Настроить RBAC для Закупщика и Руководителя</span>
+<span class="comment"># Через UI: /auth/show-groups — добавить 'catmanager/view' к нужным группам</span>
+<span class="comment"># После чего повторно запустить: php yii auth/init</span></pre>
+</div>
+
+<!-- FILES -->
+<h2 class="section-title mt-5">Полный список изменений</h2>
+<div class="row g-3">
+  <div class="col-md-6">
+    <div class="card border-0 shadow-sm">
+      <div class="card-header bg-success text-white">Новые файлы</div>
+      <div class="card-body">
+        <div class="file-list"><ul class="mb-0 ps-3">
+          <li class="new">migrations/m260422_100000_add_classification_fields_to_nomenclature.php</li>
+          <li class="new">migrations/m260422_110000_add_catmanager_rbac.php</li>
+          <li class="new">migrations/m260422_120000_create_product_concepts_table.php</li>
+          <li class="new">controllers/Products1cNomenclatureMarkupController.php</li>
+          <li class="new">records/ProductConcept.php</li>
+          <li class="new">commands/Products1cConceptController.php</li>
+          <li class="new">views/products1c-nomenclature-markup/index.php</li>
+          <li class="new">web/js/products1cNomenclatureMarkup/index.js</li>
+        </ul></div>
+      </div>
+    </div>
+  </div>
+  <div class="col-md-6">
+    <div class="card border-0 shadow-sm">
+      <div class="card-header bg-warning text-dark">Изменённые файлы</div>
+      <div class="card-body">
+        <div class="file-list"><ul class="mb-0 ps-3">
+          <li class="modified">records/Products1cNomenclature.php — rules + @property</li>
+          <li class="modified">records/AdminGroup.php — GROUP_CATMANAGER const</li>
+          <li class="modified">controllers/Products1cNomenclatureActualityController.php — AccessControl, concepts loading</li>
+          <li class="modified">controllers/AssortmentLabelController.php — AccessControl</li>
+          <li class="modified">views/products1c-nomenclature-actuality/index.php — tabs, concepts, bulk toolbar</li>
+          <li class="modified">web/js/products1cNomenclatureActuality/index.js — bulk section</li>
+        </ul></div>
+      </div>
+    </div>
+  </div>
+</div>
+
+</div><!-- /container -->
+
+<footer class="mt-5 py-4 border-top">
+  <div class="container text-center text-muted small">
+    ERP-325 Каталог категорийного менеджера · Реализация: 2026-04-22 · feature_fomichev_ERP-325J_category_manager_catalog
+  </div>
+</footer>
+
+<script>
+mermaid.initialize({
+  startOnLoad: true,
+  theme: 'neutral',
+  themeVariables: { primaryColor: '#f3e8ff', primaryTextColor: '#1e3a5f', lineColor: '#7c3aed' }
+});
+</script>
+<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+</html>
diff --git a/erp24/migrations/m241228_092653_add_target_date_column_to_sent_kogort_table.php b/erp24/migrations/m241228_092653_add_target_date_column_to_sent_kogort_table.php
new file mode 100644 (file)
index 0000000..75f01ab
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Handles adding columns to table `{{%sent_kogort}}`.
+ */
+class m241228_092653_add_target_date_column_to_sent_kogort_table extends Migration
+{
+    const TABLE_NAME = 'erp24.sent_kogort';
+    /**
+     * {@inheritdoc}
+     */
+    public function safeUp()
+    {
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME) === null) {
+            return;
+        }
+
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME)->getColumn('target_date') === null) {
+            $this->addColumn(self::TABLE_NAME,
+                'target_date',
+                $this->date()->notNull()->comment('Целевая дата'));
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeDown()
+    {
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME) === null) {
+            return;
+        }
+
+        if ($this->db->schema->getTableSchema(self::TABLE_NAME)->getColumn('target_date') !== null) {
+            $this->dropColumn(self::TABLE_NAME, 'target_date');
+        }
+    }
+}
diff --git a/erp24/migrations/m260420_120000_create_audit_log_table.php b/erp24/migrations/m260420_120000_create_audit_log_table.php
new file mode 100644 (file)
index 0000000..7beaf9b
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Создаёт таблицу audit_log как shared infrastructure для ERP-325 (catmanager) и ERP-300 (справочник поставщика).
+ * Партиционирование по кварталам через PostgreSQL PARTITION BY RANGE(created_at).
+ *
+ * Для добавления нового квартала:
+ *   CREATE TABLE audit_log_YYYY_qN PARTITION OF audit_log
+ *       FOR VALUES FROM ('YYYY-MM-01') TO ('YYYY-MM-01');
+ */
+class m260420_120000_create_audit_log_table extends Migration
+{
+    private const TABLE = 'audit_log';
+
+    public function safeUp(): void
+    {
+        if ($this->db->getTableSchema(self::TABLE)) {
+            return;
+        }
+
+        // Родительская таблица с партиционированием по дате
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log (
+                id          BIGSERIAL,
+                entity_type VARCHAR(100)  NOT NULL,
+                entity_id   TEXT,
+                action      VARCHAR(50)   NOT NULL,
+                old_values  JSONB,
+                new_values  JSONB,
+                user_id     INTEGER,
+                user_role   VARCHAR(100),
+                ip_address  VARCHAR(45),
+                created_at  TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
+                PRIMARY KEY (id, created_at)
+            ) PARTITION BY RANGE (created_at)
+        SQL)->execute();
+
+        // Кварталы 2026
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2026_q1 PARTITION OF audit_log
+                FOR VALUES FROM ('2026-01-01') TO ('2026-04-01')
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2026_q2 PARTITION OF audit_log
+                FOR VALUES FROM ('2026-04-01') TO ('2026-07-01')
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2026_q3 PARTITION OF audit_log
+                FOR VALUES FROM ('2026-07-01') TO ('2026-10-01')
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2026_q4 PARTITION OF audit_log
+                FOR VALUES FROM ('2026-10-01') TO ('2027-01-01')
+        SQL)->execute();
+
+        // Кварталы 2027
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2027_q1 PARTITION OF audit_log
+                FOR VALUES FROM ('2027-01-01') TO ('2027-04-01')
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2027_q2 PARTITION OF audit_log
+                FOR VALUES FROM ('2027-04-01') TO ('2027-07-01')
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2027_q3 PARTITION OF audit_log
+                FOR VALUES FROM ('2027-07-01') TO ('2027-10-01')
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE TABLE audit_log_2027_q4 PARTITION OF audit_log
+                FOR VALUES FROM ('2027-10-01') TO ('2028-01-01')
+        SQL)->execute();
+
+        // Индексы на родительской таблице — автоматически наследуются партициями в PG 11+
+        $this->db->createCommand(<<<SQL
+            CREATE INDEX idx_audit_log_entity ON audit_log (entity_type, entity_id)
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE INDEX idx_audit_log_user ON audit_log (user_id)
+        SQL)->execute();
+
+        $this->db->createCommand(<<<SQL
+            CREATE INDEX idx_audit_log_created ON audit_log (created_at)
+        SQL)->execute();
+    }
+
+    public function safeDown(): void
+    {
+        // Партиции удаляются каскадно вместе с родительской таблицей
+        $this->db->createCommand('DROP TABLE IF EXISTS audit_log CASCADE')->execute();
+    }
+}
diff --git a/erp24/migrations/m260420_130000_create_assortment_tables.php b/erp24/migrations/m260420_130000_create_assortment_tables.php
new file mode 100644 (file)
index 0000000..7307dc5
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * ERP-328: Ассортиментная матрица — справочник лейблов и N:M привязка к товарам.
+ * Зависит от: m260420_120000_create_audit_log_table (audit_log).
+ */
+class m260420_130000_create_assortment_tables extends Migration
+{
+    private const LABELS_TABLE     = 'assortment_labels';
+    private const ASSORTMENT_TABLE = 'product_assortment';
+
+    public function safeUp(): void
+    {
+        // ── Справочник лейблов ──────────────────────────────────────────────
+        if (!$this->db->getTableSchema(self::LABELS_TABLE)) {
+            $this->createTable(self::LABELS_TABLE, [
+                'id'           => $this->primaryKey(),
+                'name'         => $this->string(100)->notNull()->comment('Название лейбла'),
+                'channel_type' => $this->string(20)->null()
+                    ->comment('Тип канала: offline / online / marketplace / NULL'),
+                'color'        => $this->string(7)->null()->comment('HEX-цвет, например #4CAF50'),
+                'icon'         => $this->string(50)->null()->comment('CSS-класс иконки, например fa-star'),
+                'is_active'    => $this->boolean()->notNull()->defaultValue(true),
+                'created_at'   => $this->dateTime()->notNull(),
+                'updated_at'   => $this->dateTime()->null(),
+                'created_by'   => $this->integer()->notNull(),
+                'updated_by'   => $this->integer()->null(),
+            ]);
+
+            $this->createIndex('idx_assortment_labels_active', self::LABELS_TABLE, 'is_active');
+        }
+
+        // ── N:M: товар ↔ лейбл ────────────────────────────────────────────
+        if (!$this->db->getTableSchema(self::ASSORTMENT_TABLE)) {
+            $this->createTable(self::ASSORTMENT_TABLE, [
+                'id'           => $this->primaryKey(),
+                'product_guid' => $this->string(255)->notNull()
+                    ->comment('FK → products_1c_nomenclature.id'),
+                'label_id'     => $this->integer()->notNull()
+                    ->comment('FK → assortment_labels.id'),
+                'created_at'   => $this->dateTime()->notNull(),
+                'created_by'   => $this->integer()->notNull(),
+            ]);
+
+            $this->createIndex(
+                'uq_product_assortment',
+                self::ASSORTMENT_TABLE,
+                ['product_guid', 'label_id'],
+                true  // unique
+            );
+
+            $this->createIndex(
+                'idx_product_assortment_guid',
+                self::ASSORTMENT_TABLE,
+                'product_guid'
+            );
+
+            $this->addForeignKey(
+                'fk_product_assortment_label',
+                self::ASSORTMENT_TABLE, 'label_id',
+                self::LABELS_TABLE, 'id',
+                'CASCADE', 'CASCADE'
+            );
+        }
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropTable(self::ASSORTMENT_TABLE);
+        $this->dropTable(self::LABELS_TABLE);
+    }
+}
diff --git a/erp24/migrations/m260420_140000_create_product_scores_table.php b/erp24/migrations/m260420_140000_create_product_scores_table.php
new file mode 100644 (file)
index 0000000..973656d
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * ERP-329: Оценки и комментарии КМ по маппингам поставщиков.
+ * Soft FK на product_mappings.id (таблица из ветки ERP-300).
+ */
+class m260420_140000_create_product_scores_table extends Migration
+{
+    private const TABLE = 'product_scores';
+
+    public function safeUp(): void
+    {
+        if ($this->db->getTableSchema(self::TABLE)) {
+            return;
+        }
+
+        $this->createTable(self::TABLE, [
+            'id'             => $this->primaryKey(),
+            'mapping_id'     => $this->integer()->notNull()
+                ->comment('Soft FK → product_mappings.id'),
+            'score'          => $this->smallInteger()->null()
+                ->comment('Оценка КМ 1-5, NULL = не выставлена'),
+            'comment'        => $this->text()->null()
+                ->comment('Комментарий категорийного менеджера'),
+            'km_user_id'     => $this->integer()->notNull()
+                ->comment('Автор оценки/комментария (admin.id)'),
+            'km_comment_at'  => $this->dateTime()->null()
+                ->comment('Время последнего редактирования комментария'),
+            'created_at'     => $this->dateTime()->notNull(),
+            'updated_at'     => $this->dateTime()->null(),
+        ]);
+
+        $this->execute('ALTER TABLE ' . self::TABLE . ' ADD CONSTRAINT chk_ps_score CHECK (score BETWEEN 1 AND 5)');
+
+        $this->createIndex('uq_product_scores_mapping', self::TABLE, 'mapping_id', true);
+        $this->createIndex('idx_product_scores_km_user', self::TABLE, 'km_user_id');
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropTable(self::TABLE);
+    }
+}
diff --git a/erp24/migrations/m260420_150000_create_store_product_ratings_table.php b/erp24/migrations/m260420_150000_create_store_product_ratings_table.php
new file mode 100644 (file)
index 0000000..93a57e7
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * ERP-329: Оценки магазинов по товарам.
+ * avg_store_score — materialized cache, обновляется при каждой записи.
+ */
+class m260420_150000_create_store_product_ratings_table extends Migration
+{
+    private const TABLE = 'store_product_ratings';
+
+    public function safeUp(): void
+    {
+        if ($this->db->getTableSchema(self::TABLE)) {
+            return;
+        }
+
+        $this->createTable(self::TABLE, [
+            'id'              => $this->primaryKey(),
+            'product_guid'    => $this->string(255)->notNull()
+                ->comment('Soft FK → products_1c_nomenclature.id'),
+            'store_id'        => $this->integer()->notNull()
+                ->comment('FK → city_store.id'),
+            'rating'          => $this->decimal(3, 1)->notNull()
+                ->comment('Оценка магазина 1.0 – 5.0'),
+            'created_by'      => $this->integer()->notNull(),
+            'created_at'      => $this->dateTime()->notNull(),
+            'updated_by'      => $this->integer()->null(),
+            'updated_at'      => $this->dateTime()->null(),
+        ]);
+
+        $this->execute('ALTER TABLE ' . self::TABLE . ' ADD CONSTRAINT chk_spr_rating CHECK (rating BETWEEN 1 AND 5)');
+
+        $this->createIndex('uq_store_product_ratings', self::TABLE, ['product_guid', 'store_id'], true);
+        $this->createIndex('idx_spr_product_guid', self::TABLE, 'product_guid');
+        $this->createIndex('idx_spr_store_id', self::TABLE, 'store_id');
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropTable(self::TABLE);
+    }
+}
diff --git a/erp24/migrations/m260422_100000_add_classification_fields_to_nomenclature.php b/erp24/migrations/m260422_100000_add_classification_fields_to_nomenclature.php
new file mode 100644 (file)
index 0000000..a07587e
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * ERP-333 CM-2.1: добавляет поля разметки классификации в products_1c_nomenclature.
+ */
+class m260422_100000_add_classification_fields_to_nomenclature extends Migration
+{
+    private const TABLE = 'products_1c_nomenclature';
+    private const INDEX = 'idx_nomenclature_classification_status';
+
+    public function safeUp(): void
+    {
+        $cols = array_keys($this->db->getTableSchema(self::TABLE)->columns);
+
+        if (!in_array('classification_status', $cols, true)) {
+            $this->addColumn(self::TABLE, 'classification_status',
+                $this->string(20)->notNull()->defaultValue('unclassified')
+                    ->comment('not_needed | unclassified | pending | approved'));
+        }
+        if (!in_array('confidence', $cols, true)) {
+            $this->addColumn(self::TABLE, 'confidence',
+                $this->smallInteger()->null()->comment('Уверенность ML 0-100 %'));
+        }
+        if (!in_array('classified_by', $cols, true)) {
+            $this->addColumn(self::TABLE, 'classified_by',
+                $this->integer()->null()->comment('ID пользователя, подтвердившего разметку'));
+        }
+        if (!in_array('classified_at', $cols, true)) {
+            $this->addColumn(self::TABLE, 'classified_at',
+                $this->dateTime()->null()->comment('Дата подтверждения'));
+        }
+
+        $this->createIndex(self::INDEX, self::TABLE, 'classification_status');
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropIndex(self::INDEX, self::TABLE);
+        foreach (['classified_at', 'classified_by', 'confidence', 'classification_status'] as $col) {
+            $this->dropColumn(self::TABLE, $col);
+        }
+    }
+}
diff --git a/erp24/migrations/m260422_110000_add_catmanager_rbac.php b/erp24/migrations/m260422_110000_add_catmanager_rbac.php
new file mode 100644 (file)
index 0000000..cc52f8f
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * ERP-337 CM-4.2: RBAC для категорийного менеджера.
+ *
+ * Два разрешения:
+ *   catmanager/view   — read-only (Закупщик, Руководитель, КМ)
+ *   catmanager/manage — полный CRUD каталога (КМ)
+ *
+ * После применения миграции запустить: php yii auth/init
+ *
+ * Группы (проверить по таблице admin_group):
+ *   GROUP_CATMANAGER = 82  — Категорийный менеджер     → manage + view
+ *   GROUP_BUYER      = ?   — Закупщик                  → view (добавить вручную через auth/show-groups)
+ *   GROUP_DIRECTOR   = ?   — Руководитель              → view (добавить вручную через auth/show-groups)
+ */
+class m260422_110000_add_catmanager_rbac extends Migration
+{
+    private const PERM_VIEW   = 'catmanager/view';
+    private const PERM_MANAGE = 'catmanager/manage';
+
+    private const GROUP_CATMANAGER = 82;
+
+    public function safeUp(): void
+    {
+        $this->upsertGroupPermissions(self::GROUP_CATMANAGER, [self::PERM_MANAGE, self::PERM_VIEW]);
+
+        // Закупщик и Руководитель: добавьте их group_id аналогично, когда будут известны.
+        // Пример: $this->upsertGroupPermissions(<GROUP_BUYER_ID>, [self::PERM_VIEW]);
+
+        \Yii::$app->cache->set('dirtyAuthSettings', true);
+    }
+
+    public function safeDown(): void
+    {
+        $toRemove = [self::PERM_VIEW, self::PERM_MANAGE];
+        $rows     = $this->db->createCommand('SELECT id, config FROM admin_group_rbac_config')->queryAll();
+
+        foreach ($rows as $row) {
+            $perms   = array_filter(array_map('trim', explode(',', (string)$row['config'])));
+            $cleaned = array_values(array_diff($perms, $toRemove));
+            $this->update('admin_group_rbac_config', ['config' => implode(',', $cleaned)], ['id' => $row['id']]);
+        }
+
+        \Yii::$app->cache->set('dirtyAuthSettings', true);
+    }
+
+    private function upsertGroupPermissions(int $groupId, array $permissions): void
+    {
+        $existing = $this->db->createCommand(
+            'SELECT config FROM admin_group_rbac_config WHERE admin_group_id = :gid',
+            [':gid' => $groupId]
+        )->queryScalar();
+
+        if ($existing === false) {
+            $this->insert('admin_group_rbac_config', [
+                'admin_group_id' => $groupId,
+                'config'         => implode(',', $permissions),
+            ]);
+        } else {
+            $current = array_filter(array_map('trim', explode(',', (string)$existing)));
+            $merged  = array_values(array_unique(array_merge($current, $permissions)));
+            $this->update(
+                'admin_group_rbac_config',
+                ['config' => implode(',', $merged)],
+                ['admin_group_id' => $groupId]
+            );
+        }
+    }
+}
diff --git a/erp24/migrations/m260422_120000_create_product_concepts_table.php b/erp24/migrations/m260422_120000_create_product_concepts_table.php
new file mode 100644 (file)
index 0000000..6ad898b
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * ERP-330 CM-1.4: Таблица концепций товара, синхронизируемых из 1С.
+ *
+ * Источник: таблица bouquet_components (создаётся 1С-интеграцией, Phase 2).
+ * Ожидаемая структура bouquet_components:
+ *   product_guid VARCHAR — GUID номенклатуры
+ *   concept_name VARCHAR — Название концепции (Классика, Премиум, Моно и т.д.)
+ *
+ * Синхронизация: php yii products1c-concept/sync (cron раз в сутки).
+ */
+class m260422_120000_create_product_concepts_table extends Migration
+{
+    private const TABLE = 'product_concepts';
+
+    public function safeUp(): void
+    {
+        if ($this->db->getTableSchema(self::TABLE)) {
+            return;
+        }
+
+        $this->createTable(self::TABLE, [
+            'id'           => $this->primaryKey(),
+            'product_guid' => $this->string(255)->notNull()->comment('FK → products_1c_nomenclature.id'),
+            'concept_name' => $this->string(255)->notNull()->comment('Название концепции из 1С'),
+            'synced_at'    => $this->dateTime()->notNull()->comment('Дата последней синхронизации'),
+        ]);
+
+        $this->createIndex('idx_product_concepts_guid', self::TABLE, 'product_guid');
+    }
+
+    public function safeDown(): void
+    {
+        $this->dropTable(self::TABLE);
+    }
+}
index 74e779b8a2d82adf41f2a23879b51674635b42c8..cbe966a42c17860864532341f68e9020ba28e87b 100755 (executable)
@@ -38,6 +38,7 @@ class AdminGroup extends ActiveRecord
     const GROUP_OPERATIONAL_DIRECTOR = 51;
     const GROUP_IT = 81;
     const GROUP_FINANCE_DIRECTOR = 9;
+    const GROUP_CATMANAGER = 82; // ERP-337: Category Manager (КМ) — полный доступ к каталогу
 
     /**
      * Возвращает список специальных групп работников (нужно для JS и логики грейдов)
diff --git a/erp24/records/AssortmentLabel.php b/erp24/records/AssortmentLabel.php
new file mode 100644 (file)
index 0000000..b72af4c
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+use yii_app\traits\AuditableTrait;
+
+/**
+ * Справочник лейблов ассортиментной матрицы.
+ *
+ * @property int         $id
+ * @property string      $name          Название лейбла
+ * @property string|null $channel_type  offline | online | marketplace | null
+ * @property string|null $color         HEX-цвет (#RRGGBB)
+ * @property string|null $icon          CSS-класс иконки
+ * @property bool        $is_active
+ * @property string      $created_at
+ * @property string|null $updated_at
+ * @property int         $created_by
+ * @property int|null    $updated_by
+ */
+class AssortmentLabel extends ActiveRecord
+{
+    use AuditableTrait;
+
+    public const CHANNEL_OFFLINE     = 'offline';
+    public const CHANNEL_ONLINE      = 'online';
+    public const CHANNEL_MARKETPLACE = 'marketplace';
+
+    public static function tableName(): string
+    {
+        return 'assortment_labels';
+    }
+
+    public function behaviors(): array
+    {
+        return [
+            [
+                'class'             => TimestampBehavior::class,
+                'createdAtAttribute' => 'created_at',
+                'updatedAtAttribute' => 'updated_at',
+                'value'             => new Expression('NOW()'),
+            ],
+            [
+                'class'              => BlameableBehavior::class,
+                'createdByAttribute' => 'created_by',
+                'updatedByAttribute' => 'updated_by',
+            ],
+        ];
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['name'], 'required'],
+            [['name'], 'string', 'max' => 100],
+            [['channel_type'], 'in', 'range' => [
+                self::CHANNEL_OFFLINE, self::CHANNEL_ONLINE, self::CHANNEL_MARKETPLACE,
+            ], 'skipOnEmpty' => true],
+            [['color'], 'match', 'pattern' => '/^#[0-9A-Fa-f]{6}$/', 'skipOnEmpty' => true],
+            [['icon'], 'string', 'max' => 50],
+            [['is_active'], 'boolean'],
+            [['channel_type', 'color', 'icon'], 'default', 'value' => null],
+            [['is_active'], 'default', 'value' => true],
+        ];
+    }
+
+    public function attributeLabels(): array
+    {
+        return [
+            'id'           => 'ID',
+            'name'         => 'Название',
+            'channel_type' => 'Тип канала',
+            'color'        => 'Цвет',
+            'icon'         => 'Иконка',
+            'is_active'    => 'Активен',
+            'created_at'   => 'Создан',
+            'updated_at'   => 'Обновлён',
+            'created_by'   => 'Создал',
+            'updated_by'   => 'Обновил',
+        ];
+    }
+
+    public function getProductAssortments(): ActiveQuery
+    {
+        return $this->hasMany(ProductAssortment::class, ['label_id' => 'id']);
+    }
+
+    /** Возвращает активные лейблы, отсортированные по имени. */
+    public static function findActive(): array
+    {
+        return static::find()
+            ->where(['is_active' => true])
+            ->orderBy(['name' => SORT_ASC])
+            ->all();
+    }
+}
diff --git a/erp24/records/AuditLog.php b/erp24/records/AuditLog.php
new file mode 100644 (file)
index 0000000..1e40f1e
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use Yii;
+use yii\db\ActiveRecord;
+
+/**
+ * Shared audit log для всех сущностей ERP24.
+ *
+ * @property int         $id
+ * @property string      $entity_type  Имя таблицы (tableName()) сущности
+ * @property string|null $entity_id    PK сущности (TEXT — поддерживает int и GUID)
+ * @property string      $action       create | update | delete | bulk_update
+ * @property array|null  $old_values   JSONB — значения до изменения
+ * @property array|null  $new_values   JSONB — значения после изменения
+ * @property int|null    $user_id      ID пользователя (admin.id)
+ * @property string|null $user_role    Имя группы (admin_group.name)
+ * @property string|null $ip_address   IP клиента
+ * @property string      $created_at   TIMESTAMPTZ
+ */
+class AuditLog extends ActiveRecord
+{
+    public const ACTION_CREATE       = 'create';
+    public const ACTION_UPDATE       = 'update';
+    public const ACTION_DELETE       = 'delete';
+    public const ACTION_BULK_UPDATE  = 'bulk_update';
+
+    public static function tableName(): string
+    {
+        return 'audit_log';
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['entity_type', 'action'], 'required'],
+            [['entity_type', 'user_role', 'ip_address'], 'string', 'max' => 100],
+            [['entity_id'], 'string', 'max' => 255],
+            [['action'], 'string', 'max' => 50],
+            [['user_id'], 'integer'],
+            [['old_values', 'new_values'], 'safe'],
+            [['created_at'], 'safe'],
+        ];
+    }
+
+    /**
+     * Записывает событие в audit_log.
+     * Ошибки записи логируются через Yii::error, но не бросают исключений —
+     * аудит не должен прерывать основную бизнес-логику.
+     */
+    public static function write(
+        string  $entityType,
+        mixed   $entityId,
+        string  $action,
+        array   $oldValues = [],
+        array   $newValues = [],
+    ): void {
+        try {
+            $app      = Yii::$app;
+            $userId   = null;
+            $userRole = null;
+
+            if ($app->has('user') && !$app->user->isGuest) {
+                $identity = $app->user->identity;
+                $userId   = $identity->id ?? null;
+                // Имя группы из связи adminGroup (Admin hasOne AdminGroup)
+                $userRole = $identity->adminGroup->name ?? null;
+            }
+
+            $ip = $app->has('request') ? ($app->request->userIP ?? null) : null;
+
+            $record = new self([
+                'entity_type' => $entityType,
+                'entity_id'   => $entityId !== null ? (string)$entityId : null,
+                'action'      => $action,
+                'old_values'  => $oldValues ?: null,
+                'new_values'  => $newValues ?: null,
+                'user_id'     => $userId,
+                'user_role'   => $userRole,
+                'ip_address'  => $ip,
+                'created_at'  => date('Y-m-d H:i:s'),
+            ]);
+
+            if (!$record->save()) {
+                Yii::error(
+                    'AuditLog write failed: ' . json_encode($record->getErrors(), JSON_UNESCAPED_UNICODE),
+                    'audit'
+                );
+            }
+        } catch (\Throwable $e) {
+            Yii::error('AuditLog exception: ' . $e->getMessage(), 'audit');
+        }
+    }
+}
diff --git a/erp24/records/ProductAssortment.php b/erp24/records/ProductAssortment.php
new file mode 100644 (file)
index 0000000..0c06e5a
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+use yii_app\traits\AuditableTrait;
+
+/**
+ * N:M связь товара с лейблами ассортиментной матрицы.
+ *
+ * @property int    $id
+ * @property string $product_guid  FK → products_1c_nomenclature.id
+ * @property int    $label_id      FK → assortment_labels.id
+ * @property string $created_at
+ * @property int    $created_by
+ */
+class ProductAssortment extends ActiveRecord
+{
+    use AuditableTrait;
+
+    public static function tableName(): string
+    {
+        return 'product_assortment';
+    }
+
+    public function behaviors(): array
+    {
+        return [
+            [
+                'class'             => TimestampBehavior::class,
+                'createdAtAttribute' => 'created_at',
+                'updatedAtAttribute' => false,
+                'value'             => new Expression('NOW()'),
+            ],
+            [
+                'class'              => BlameableBehavior::class,
+                'createdByAttribute' => 'created_by',
+                'updatedByAttribute' => false,
+            ],
+        ];
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['product_guid', 'label_id'], 'required'],
+            [['label_id', 'created_by'], 'integer'],
+            [['product_guid'], 'string', 'max' => 255],
+            [['product_guid', 'label_id'], 'unique', 'targetAttribute' => ['product_guid', 'label_id']],
+        ];
+    }
+
+    public function getLabel(): ActiveQuery
+    {
+        return $this->hasOne(AssortmentLabel::class, ['id' => 'label_id']);
+    }
+
+    public function getProduct(): ActiveQuery
+    {
+        return $this->hasOne(Products1cNomenclature::class, ['id' => 'product_guid']);
+    }
+
+    /**
+     * Возвращает все лейблы товара (с eager-load AssortmentLabel).
+     *
+     * @return AssortmentLabel[]
+     */
+    public static function findLabelsForProduct(string $productGuid): array
+    {
+        return static::find()
+            ->with('label')
+            ->where(['product_guid' => $productGuid])
+            ->all();
+    }
+}
diff --git a/erp24/records/ProductConcept.php b/erp24/records/ProductConcept.php
new file mode 100644 (file)
index 0000000..dfc3e95
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\db\ActiveRecord;
+
+/**
+ * @property int    $id
+ * @property string $product_guid FK → products_1c_nomenclature.id
+ * @property string $concept_name Название концепции из 1С
+ * @property string $synced_at    Дата синхронизации
+ */
+class ProductConcept extends ActiveRecord
+{
+    public static function tableName(): string
+    {
+        return 'product_concepts';
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['product_guid', 'concept_name', 'synced_at'], 'required'],
+            [['product_guid', 'concept_name'], 'string', 'max' => 255],
+            [['synced_at'], 'safe'],
+        ];
+    }
+}
diff --git a/erp24/records/ProductScore.php b/erp24/records/ProductScore.php
new file mode 100644 (file)
index 0000000..ff02198
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+use yii_app\traits\AuditableTrait;
+
+/**
+ * Оценка и комментарий категорийного менеджера по маппингу поставщика.
+ *
+ * @property int         $id
+ * @property int         $mapping_id     Soft FK → product_mappings.id
+ * @property int|null    $score          1-5, NULL = не выставлена
+ * @property string|null $comment
+ * @property int         $km_user_id
+ * @property string|null $km_comment_at
+ * @property string      $created_at
+ * @property string|null $updated_at
+ */
+class ProductScore extends ActiveRecord
+{
+    use AuditableTrait;
+
+    public static function tableName(): string
+    {
+        return 'product_scores';
+    }
+
+    public function behaviors(): array
+    {
+        return [
+            [
+                'class'              => TimestampBehavior::class,
+                'createdAtAttribute' => 'created_at',
+                'updatedAtAttribute' => 'updated_at',
+                'value'              => new Expression('NOW()'),
+            ],
+        ];
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['mapping_id', 'km_user_id'], 'required'],
+            [['mapping_id', 'km_user_id', 'score'], 'integer'],
+            ['score', 'in', 'range' => [1, 2, 3, 4, 5], 'skipOnEmpty' => true],
+            ['comment', 'string'],
+            [['score', 'comment', 'km_comment_at', 'updated_at'], 'default', 'value' => null],
+        ];
+    }
+
+    public function getMapping(): ActiveQuery
+    {
+        return $this->hasOne(ProductMapping::class, ['id' => 'mapping_id']);
+    }
+
+    public static function findByMapping(int $mappingId): ?self
+    {
+        return static::findOne(['mapping_id' => $mappingId]);
+    }
+}
index 36c3985802264950e109f678372ab41ef2a4c5b5..016e5a8a6093d32513a129378dfa05f2083ed15e 100644 (file)
@@ -20,6 +20,10 @@ use yii\db\ActiveQuery;
  * @property int|null $size Размер
  * @property string|null $measure Единица измерения
  * @property string|null $color Цвет
+ * @property string $classification_status not_needed|unclassified|pending|approved
+ * @property int|null $confidence Уверенность ML 0-100 %
+ * @property int|null $classified_by ID пользователя
+ * @property string|null $classified_at Дата подтверждения
  */
 class Products1cNomenclature extends \yii\db\ActiveRecord
 {
@@ -48,11 +52,15 @@ class Products1cNomenclature extends \yii\db\ActiveRecord
         return [
             [['id', 'location', 'name', 'type_num', 'category'], 'required'],
             [['size', 'species', 'subcategory', 'sort', 'measure', 'color', 'type'], 'default', 'value' => null],
-            [['size'], 'integer'],
+            [['size', 'confidence', 'classified_by'], 'integer'],
+            [['confidence'], 'integer', 'min' => 0, 'max' => 100],
             [['id', 'location', 'name', 'type_num',
                 'category', 'subcategory', 'species', 'sort', 'measure', 'color', 'type'], 'string', 'max' => 255],
             [['id'], 'unique'],
-            [['date_from','date_to'], 'safe'],
+            [['date_from', 'date_to', 'classified_at'], 'safe'],
+            ['classification_status', 'in', 'range' => ['not_needed', 'unclassified', 'pending', 'approved']],
+            ['classification_status', 'default', 'value' => 'unclassified'],
+            [['confidence', 'classified_by', 'classified_at'], 'default', 'value' => null],
         ];
     }
 
index ba043e96b237a8611240be656208ce088ba15986..3f0b806235f0cc0c0d0b2325fc5d8bd8201e3954 100644 (file)
@@ -3,6 +3,7 @@
 namespace yii_app\records;
 
 use Yii;
+use yii_app\traits\AuditableTrait;
 
 /**
  * This is the model class for table "products_1c_nomenclature_actuality".
@@ -18,6 +19,8 @@ use Yii;
  */
 class Products1cNomenclatureActuality extends \yii\db\ActiveRecord
 {
+    use AuditableTrait;
+
 
 
     /**
@@ -71,4 +74,27 @@ class Products1cNomenclatureActuality extends \yii\db\ActiveRecord
         );
     }
 
+    public function getStatus(): string
+    {
+        $now = date('Y-m-d H:i:s');
+        if ($this->date_from <= $now && ($this->date_to === null || $this->date_to >= $now)) {
+            return 'active';
+        }
+        return $this->date_from > $now ? 'future' : 'past';
+    }
+
+    public function getLabel(): string
+    {
+        static $months = [
+            1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр',
+            5 => 'Май', 6 => 'Июн', 7 => 'Июл', 8 => 'Авг',
+            9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек',
+        ];
+        $from = new \DateTime($this->date_from);
+        $to   = new \DateTime($this->date_to ?? $this->date_from);
+        return $months[(int)$from->format('n')] . ' ' . $from->format('Y')
+            . ' – '
+            . $months[(int)$to->format('n')] . ' ' . $to->format('Y');
+    }
+
 }
diff --git a/erp24/records/StoreProductRating.php b/erp24/records/StoreProductRating.php
new file mode 100644 (file)
index 0000000..dd9fdc8
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+use yii_app\traits\AuditableTrait;
+
+/**
+ * Оценка магазина по товару (5-балльная).
+ * avg_store_score вычисляется при запросе через SQL AVG.
+ *
+ * @property int     $id
+ * @property string  $product_guid  Soft FK → products_1c_nomenclature.id
+ * @property int     $store_id      FK → city_store.id
+ * @property float   $rating        1.0 – 5.0
+ * @property int     $created_by
+ * @property string  $created_at
+ * @property int|null $updated_by
+ * @property string|null $updated_at
+ */
+class StoreProductRating extends ActiveRecord
+{
+    use AuditableTrait;
+
+    public static function tableName(): string
+    {
+        return 'store_product_ratings';
+    }
+
+    public function behaviors(): array
+    {
+        return [
+            [
+                'class'              => TimestampBehavior::class,
+                'createdAtAttribute' => 'created_at',
+                'updatedAtAttribute' => 'updated_at',
+                'value'              => new Expression('NOW()'),
+            ],
+            [
+                'class'              => BlameableBehavior::class,
+                'createdByAttribute' => 'created_by',
+                'updatedByAttribute' => 'updated_by',
+            ],
+        ];
+    }
+
+    public function rules(): array
+    {
+        return [
+            [['product_guid', 'store_id', 'rating'], 'required'],
+            ['product_guid', 'string', 'max' => 255],
+            ['store_id', 'integer'],
+            ['rating', 'number', 'min' => 1, 'max' => 5],
+        ];
+    }
+
+    public static function avgForProduct(string $productGuid): ?float
+    {
+        $avg = static::find()
+            ->where(['product_guid' => $productGuid])
+            ->average('rating');
+
+        return $avg !== null ? round((float)$avg, 2) : null;
+    }
+}
diff --git a/erp24/traits/AuditableTrait.php b/erp24/traits/AuditableTrait.php
new file mode 100644 (file)
index 0000000..1235877
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\traits;
+
+use yii_app\records\AuditLog;
+
+/**
+ * Подключается к ActiveRecord-моделям для автоматической записи в audit_log.
+ *
+ * Использование:
+ *   class MyModel extends \yii\db\ActiveRecord
+ *   {
+ *       use AuditableTrait;
+ *       // ...
+ *   }
+ *
+ * Для bulk-операций вызывать вручную:
+ *   MyModel::logBulkUpdate([1, 2, 3], oldValues: [], newValues: ['status' => 'active']);
+ */
+trait AuditableTrait
+{
+    /**
+     * Логирует создание/обновление.
+     * Для update пишет только реально изменённые атрибуты — не срабатывает на no-op save.
+     */
+    public function afterSave(bool $insert, array $changedAttributes): void
+    {
+        parent::afterSave($insert, $changedAttributes);
+
+        if ($insert) {
+            AuditLog::write(
+                entityType: static::tableName(),
+                entityId:   $this->getPrimaryKey(),
+                action:     AuditLog::ACTION_CREATE,
+                newValues:  $this->getAttributes(),
+            );
+            return;
+        }
+
+        // No-op save — ничего не изменилось, не пишем
+        if (empty($changedAttributes)) {
+            return;
+        }
+
+        // Берём новые значения только для изменённых атрибутов
+        $newValues = array_intersect_key($this->getAttributes(), $changedAttributes);
+
+        AuditLog::write(
+            entityType: static::tableName(),
+            entityId:   $this->getPrimaryKey(),
+            action:     AuditLog::ACTION_UPDATE,
+            oldValues:  $changedAttributes,
+            newValues:  $newValues,
+        );
+    }
+
+    /**
+     * Логирует удаление записи.
+     */
+    public function afterDelete(): void
+    {
+        parent::afterDelete();
+
+        AuditLog::write(
+            entityType: static::tableName(),
+            entityId:   $this->getPrimaryKey(),
+            action:     AuditLog::ACTION_DELETE,
+            oldValues:  $this->getAttributes(),
+        );
+    }
+
+    /**
+     * Логирует массовую операцию.
+     * Пишет одну запись с массивом entity_ids в new_values.
+     *
+     * @param array $entityIds   Список PK затронутых записей
+     * @param array $oldValues   Общие старые значения (если применимо)
+     * @param array $newValues   Применённые изменения
+     */
+    public static function logBulkUpdate(
+        array $entityIds,
+        array $oldValues = [],
+        array $newValues = [],
+    ): void {
+        AuditLog::write(
+            entityType: static::tableName(),
+            entityId:   null,
+            action:     AuditLog::ACTION_BULK_UPDATE,
+            oldValues:  $oldValues,
+            newValues:  array_merge($newValues, ['entity_ids' => $entityIds]),
+        );
+    }
+}
diff --git a/erp24/views/assortment-label/index.php b/erp24/views/assortment-label/index.php
new file mode 100644 (file)
index 0000000..1fd0020
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+
+use yii\helpers\Html;
+use yii\web\View;
+
+/* @var $this yii\web\View */
+/* @var int $totalProducts */
+/* @var int $withLabel */
+/* @var float $coverage */
+
+$this->title = 'Справочник лейблов ассортиментной матрицы';
+$this->params['breadcrumbs'][] = $this->title;
+$this->registerJsFile('/js/assortmentLabel/index.js', ['position' => View::POS_END]);
+?>
+
+<div class="assortment-label-index p-4">
+
+    <div class="d-flex justify-content-between align-items-center mb-4">
+        <h1 class="mb-0"><?= Html::encode($this->title) ?></h1>
+        <button class="btn btn-primary" id="addLabelBtn">
+            <i class="fa fa-plus me-1"></i> Добавить лейбл
+        </button>
+    </div>
+
+    <!-- Покрытие -->
+    <div class="row g-3 mb-4">
+        <div class="col-auto">
+            <div class="card border-0 bg-light px-4 py-3 text-center" style="min-width:160px">
+                <div class="fs-2 fw-bold text-primary" id="statCoverage"><?= $coverage ?>%</div>
+                <div class="text-muted small">покрытие матрицей</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 bg-light px-4 py-3 text-center" style="min-width:160px">
+                <div class="fs-2 fw-bold" id="statWithLabel"><?= $withLabel ?></div>
+                <div class="text-muted small">товаров с лейблом</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 bg-light px-4 py-3 text-center" style="min-width:160px">
+                <div class="fs-2 fw-bold" id="statTotal"><?= $totalProducts ?></div>
+                <div class="text-muted small">всего товаров</div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Таблица лейблов -->
+    <div id="labelsTableWrap">
+        <div class="text-center py-4">
+            <span class="spinner-border spinner-border-sm"></span> Загрузка...
+        </div>
+    </div>
+
+</div>
+
+<!-- ==================== Modal: create/edit лейбл ==================== -->
+<div class="modal fade" id="labelModal" tabindex="-1" aria-hidden="true">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="labelModalTitle">Новый лейбл</h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+            </div>
+            <div class="modal-body">
+                <input type="hidden" id="labelId">
+
+                <div class="mb-3">
+                    <label class="form-label">Название <span class="text-danger">*</span></label>
+                    <input type="text" class="form-control" id="labelName" maxlength="100" placeholder="Например: Топ продаж">
+                </div>
+
+                <div class="mb-3">
+                    <label class="form-label">Тип канала</label>
+                    <select class="form-select" id="labelChannelType">
+                        <option value="">— не указан —</option>
+                        <option value="offline">Offline</option>
+                        <option value="online">Online</option>
+                        <option value="marketplace">Marketplace</option>
+                    </select>
+                    <div class="form-text">Связывает лейбл с каналом продаж для планограммы</div>
+                </div>
+
+                <div class="mb-3">
+                    <label class="form-label">Цвет</label>
+                    <div class="d-flex align-items-center gap-2">
+                        <input type="color" class="form-control form-control-color" id="labelColor" value="#4CAF50" style="width:48px;height:38px">
+                        <input type="text" class="form-control font-monospace" id="labelColorHex" maxlength="7" placeholder="#4CAF50" style="width:110px">
+                        <span class="text-muted small">#RRGGBB</span>
+                    </div>
+                </div>
+
+                <div class="mb-3">
+                    <label class="form-label">CSS-класс иконки</label>
+                    <input type="text" class="form-control" id="labelIcon" maxlength="50" placeholder="fa-star">
+                    <div class="form-text">Например: <code>fa-star</code>, <code>fa-fire</code></div>
+                </div>
+
+                <div class="form-check">
+                    <input class="form-check-input" type="checkbox" id="labelIsActive" checked>
+                    <label class="form-check-label" for="labelIsActive">Активен</label>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                <button type="button" class="btn btn-primary" id="saveLabelBtn">Сохранить</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- ==================== Modal: confirm deactivation ==================== -->
+<div class="modal fade" id="deactivateConfirmModal" tabindex="-1" aria-hidden="true">
+    <div class="modal-dialog modal-sm">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title">Деактивировать лейбл?</h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+            </div>
+            <div class="modal-body">
+                <p id="deactivateMessage" class="mb-0"></p>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
+                <button type="button" class="btn btn-warning" id="confirmDeactivateBtn">Деактивировать</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- Toast-контейнер -->
+<div id="toastContainer" class="position-fixed top-0 end-0 p-3" style="z-index:9999"></div>
+
+<script>
+window.assortmentLabelConfig = {
+    urls: {
+        list:         '<?= \yii\helpers\Url::to(['/assortment-label/ajax-list']) ?>',
+        save:         '<?= \yii\helpers\Url::to(['/assortment-label/ajax-save']) ?>',
+        toggleActive: '<?= \yii\helpers\Url::to(['/assortment-label/ajax-toggle-active']) ?>',
+        delete:       '<?= \yii\helpers\Url::to(['/assortment-label/ajax-delete']) ?>',
+    }
+};
+</script>
index 70667730b355b591739cfdfd69ee64cb3022d3e4..8b1c9c190f780eb44fd2b1b6b04d46cd996bd18e 100644 (file)
@@ -3,9 +3,8 @@
 use kartik\form\ActiveForm;
 use kartik\grid\GridView;
 use yii\helpers\Html;
+use yii\helpers\Url;
 use yii\web\View;
-use yii_app\records\Products1cNomenclatureActuality;
-
 
 /* @var $this yii\web\View */
 /* @var $filter yii\base\DynamicModel */
@@ -17,20 +16,24 @@ use yii_app\records\Products1cNomenclatureActuality;
 /* @var array $colors */
 /* @var array $sorts */
 /* @var array $sizes */
+/* @var array|null $counters */
+/* @var int $pageSize */
+/* @var string $sortBy */
 
 $this->title = 'Актуализация номенклатуры';
 $this->params['breadcrumbs'][] = $this->title;
 $this->registerJsFile('/js/products1cNomenclatureActuality/index.js', ['position' => View::POS_END]);
-// Список месяцев-годов для выпадающих списков
-function monthList()
+$this->registerCss('.concept-chip{display:inline-block;font-size:10px;padding:2px 7px;border-radius:4px;font-weight:500;background:#f3e8ff;color:#7c3aed;border:1px dashed #d8b4fe;}');
+
+function monthList(): array
 {
     $list = [];
-    $tz  = new DateTimeZone('Europe/Moscow');
-    $now = new DateTime('now', $tz);
+    $tz   = new DateTimeZone('Europe/Moscow');
+    $now  = new DateTime('now', $tz);
     $start = (clone $now)->modify('first day of january last year');
-    $end  = (clone $now)->modify('last day of december next year');
+    $end   = (clone $now)->modify('last day of december next year');
     while ($start <= $end) {
-        $key = $start->format('Y-m');
+        $key        = $start->format('Y-m');
         $list[$key] = $start->format('Y‑m');
         $start->modify('+1 month');
     }
@@ -38,9 +41,74 @@ function monthList()
 }
 
 $months = monthList();
-$monthOptions = '';
-foreach ($months as $k => $v) {
-    $monthOptions .= "<option value=\"$k\">$v</option>";
+
+function renderActualityChips(array $actualities): string
+{
+    if (empty($actualities)) {
+        return '<span class="text-danger" title="Нет интервалов"><i class="fa fa-exclamation-circle"></i></span>';
+    }
+
+    $statusClasses = [
+        'active' => 'bg-success',
+        'future' => 'bg-primary',
+        'past'   => 'bg-secondary',
+    ];
+
+    $chips = [];
+    foreach ($actualities as $act) {
+        $status = $act->getStatus();
+        $cls    = $statusClasses[$status] ?? 'bg-secondary';
+        $style  = $status === 'past' ? ' style="text-decoration:line-through"' : '';
+        $chips[] = '<span class="badge ' . $cls . ' me-1"' . $style . '>' . Html::encode($act->getLabel()) . '</span>';
+    }
+
+    $visible = array_slice($chips, 0, 3);
+    $hidden  = count($chips) - count($visible);
+    $html    = implode('', $visible);
+    if ($hidden > 0) {
+        $html .= '<span class="text-muted small ms-1">+' . $hidden . ' ещё</span>';
+    }
+    return $html;
+}
+
+function renderConceptChips(array $conceptNames): string
+{
+    if (empty($conceptNames)) {
+        return '';
+    }
+    $chips = '';
+    foreach ($conceptNames as $name) {
+        $chips .= '<span class="concept-chip me-1 mb-1">'
+            . '<i class="fa fa-palette" style="font-size:8px;margin-right:3px;"></i>'
+            . Html::encode($name)
+            . '</span>';
+    }
+    return '<span class="concept-chips-wrap d-block mt-1">' . $chips . '</span>';
+}
+
+function renderLabelChips(array $labels, string $productGuid): string
+{
+    if (empty($labels)) {
+        return '<span class="text-muted small">—</span>';
+    }
+
+    $html = '';
+    foreach ($labels as $label) {
+        $color   = Html::encode($label->color ?? '#6c757d');
+        $name    = Html::encode($label->name);
+        $labelId = (int)$label->id;
+        $html .= '<span class="label-chip d-inline-flex align-items-center me-1 mb-1 badge" '
+            . 'style="background-color:' . $color . ';cursor:default" '
+            . 'data-guid="' . Html::encode($productGuid) . '" '
+            . 'data-label-id="' . $labelId . '">'
+            . $name
+            . '<button type="button" class="remove-label-chip btn-close btn-close-white ms-1" '
+            . 'style="font-size:.6rem" aria-label="Убрать" '
+            . 'data-guid="' . Html::encode($productGuid) . '" '
+            . 'data-label-id="' . $labelId . '"></button>'
+            . '</span>';
+    }
+    return '<span class="label-chips-wrap">' . $html . '</span>';
 }
 ?>
 
@@ -48,17 +116,34 @@ foreach ($months as $k => $v) {
 
     <h1><?= Html::encode($this->title) ?></h1>
 
-    <!-- Форма фильтров -->
+    <!-- Таб-навигация -->
+    <ul class="nav nav-tabs mb-4">
+        <li class="nav-item">
+            <a class="nav-link active" href="<?= Url::to(['/products1c-nomenclature-actuality/index']) ?>">
+                <i class="fa fa-calendar-check-o me-1"></i>Актуальность ассортимента
+            </a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link" href="<?= Url::to(['/products1c-nomenclature-markup/index']) ?>">
+                <i class="fa fa-tag me-1"></i>Разметка
+            </a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link" href="<?= Url::to(['/assortment-label/index']) ?>">
+                <i class="fa fa-th-large me-1"></i>Ассортиментная матрица
+            </a>
+        </li>
+    </ul>
+
     <?php $formFilter = ActiveForm::begin([
-        'method' => 'get',
-        'action' => ['index'],
+        'method'  => 'get',
+        'action'  => ['index'],
         'options' => ['class' => 'mb-4'],
     ]); ?>
 
     <div class="row">
 
-        <!-- 1-я колонка: основные фильтры -->
-        <div class="col-6 ">
+        <div class="col-6">
             <div class="mb-2 fw-bold">Номенклатура</div>
             <div class="row mb-3">
                 <div class="col">
@@ -67,8 +152,7 @@ foreach ($months as $k => $v) {
                             $categories,
                             ['prompt' => 'Категория', 'id' => 'filter-category']
                         )->label(false) ?>
-
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-category" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-category">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -79,8 +163,7 @@ foreach ($months as $k => $v) {
                             $types,
                             ['prompt' => 'Тип', 'id' => 'filter-type']
                         )->label(false) ?>
-
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-type" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-type">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -91,8 +174,7 @@ foreach ($months as $k => $v) {
                             $colors,
                             ['prompt' => 'Цвет', 'id' => 'filter-color']
                         )->label(false) ?>
-
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-color" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-color">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -103,10 +185,9 @@ foreach ($months as $k => $v) {
                     <div class="d-flex justify-content-between">
                         <?= $formFilter->field($filter, 'subcategory', ['options' => ['class' => 'w-90']])->dropDownList(
                             $subcategories,
-                            ['prompt' => 'Подкатегория', 'id' => 'filter-subcategory', 'class' => 'w-100']
+                            ['prompt' => 'Подкатегория', 'id' => 'filter-subcategory']
                         )->label(false) ?>
-
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-subcategory" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-subcategory">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -117,7 +198,7 @@ foreach ($months as $k => $v) {
                             $sorts,
                             ['prompt' => 'Сорт', 'id' => 'filter-sort']
                         )->label(false) ?>
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-sort" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-sort">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -131,7 +212,7 @@ foreach ($months as $k => $v) {
                             $species,
                             ['prompt' => 'Вид', 'id' => 'filter-species']
                         )->label(false) ?>
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-species" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-species">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -142,7 +223,7 @@ foreach ($months as $k => $v) {
                             $sizes,
                             ['prompt' => 'Размер', 'id' => 'filter-size']
                         )->label(false) ?>
-                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-size" >
+                        <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-size">
                             <i class="fa fa-times"></i>
                         </div>
                     </div>
@@ -151,21 +232,14 @@ foreach ($months as $k => $v) {
             </div>
         </div>
 
-        <!-- 2-я колонка: даты актуальности -->
-        <div class="col-3  ps-4" style="border-left: #ccc solid 1px">
+        <div class="col-3 ps-4" style="border-left: #ccc solid 1px">
             <div class="mb-2 fw-bold">Актуальность ассортимента</div>
             <div class="mb-3">
                 <div class="d-flex justify-content-between">
                     <?= $formFilter->field($filter, 'date_from', ['options' => ['class' => 'w-100']])
-                        ->dropDownList($months,
-                            [
-                                'prompt' => 'Выбрать дату от',
-                                'id' => 'filter-date-from',
-                                'class' => ''
-                            ]
-                        )
+                        ->dropDownList($months, ['prompt' => 'Выбрать дату от', 'id' => 'filter-date-from'])
                         ->label(false) ?>
-                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-date-from" >
+                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-date-from">
                         <i class="fa fa-times"></i>
                     </div>
                 </div>
@@ -173,183 +247,364 @@ foreach ($months as $k => $v) {
             <div>
                 <div class="d-flex justify-content-between">
                     <?= $formFilter->field($filter, 'date_to', ['options' => ['class' => 'w-100']])
-                        ->dropDownList($months,
-                            [
-                                'prompt' => 'Выбрать дату до',
-                                'id' => 'filter-date-to',
-                                'class' => ''
-                            ]
-                        )
+                        ->dropDownList($months, ['prompt' => 'Выбрать дату до', 'id' => 'filter-date-to'])
                         ->label(false) ?>
-                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-date-to" >
+                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-date-to">
                         <i class="fa fa-times"></i>
                     </div>
                 </div>
             </div>
             <div class="mb-3">
                 <?= $formFilter->field($filter, 'onlyActive')->checkbox([
-                    'label' => 'Только активные',
+                    'label'   => 'Только активные',
                     'uncheck' => 0,
                     'checked' => (bool)$filter->onlyActive,
-                    'id' => 'onlyActiveCheckbox'
+                    'id'      => 'onlyActiveCheckbox',
                 ])->label(false) ?>
-
                 <?= $formFilter->field($filter, 'onlyInactive')->checkbox([
-                    'label' => 'Только неактивные',
+                    'label'   => 'Только неактивные',
                     'uncheck' => 0,
                     'checked' => (bool)$filter->onlyInactive,
-                    'id' => 'onlyInactiveCheckbox'
+                    'id'      => 'onlyInactiveCheckbox',
                 ])->label(false) ?>
             </div>
         </div>
 
-        <!-- 3-я колонка: поставщик/плантация + кнопка Применить -->
         <div class="col-2 ps-4" style="border-left: #ccc solid 1px">
             <div class="mb-2 fw-bold">Поставщики</div>
             <div class="mb-3">
-
                 <div class="input-group">
                     <?= Html::dropDownList('supplier', null,
-                        ['Астра','Бифлористика'],
+                        ['Астра', 'Бифлористика'],
                         ['class' => 'form-select', 'id' => 'filter-supplier', 'prompt' => 'Поставщик']) ?>
-                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-supplier" >
+                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-supplier">
                         <i class="fa fa-times"></i>
                     </div>
                 </div>
             </div>
             <div class="mb-4">
-
                 <div class="input-group">
                     <?= Html::dropDownList('plantation', null,
-                        ['Плантация1','Плантация2'],
+                        ['Плантация1', 'Плантация2'],
                         ['class' => 'form-select', 'id' => 'filter-plantation', 'prompt' => 'Плантация']) ?>
-                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center  clear-btn" data-target="filter-plantation" >
+                    <div class="mb-4 ms-1 d-flex justify-content-center align-items-center clear-btn" data-target="filter-plantation">
                         <i class="fa fa-times"></i>
                     </div>
                 </div>
             </div>
-
         </div>
-        <div class="col-1 ps-4 d-flex justify-content-end align-items-end" >
 
-                <?= Html::submitButton('Применить', ['class' => 'btn btn-primary w-100']) ?>
+        <div class="col-1 ps-4 d-flex flex-column justify-content-end align-items-stretch gap-2">
+            <?= Html::submitButton('Применить', ['class' => 'btn btn-primary w-100']) ?>
+            <a href="<?= \yii\helpers\Url::to(array_merge(
+                ['/products1c-nomenclature-actuality/export-xlsx'],
+                array_filter(Yii::$app->request->get(), static fn($v) => $v !== '' && $v !== null)
+            )) ?>" class="btn btn-outline-success w-100">
+                <i class="fa fa-file-excel-o"></i> XLSX
+            </a>
+        </div>
+    </div>
 
+    <!-- Поиск по наименованию -->
+    <div class="row mt-2">
+        <div class="col-8">
+            <?= $formFilter->field($filter, 'search', ['options' => ['class' => 'mb-0']])
+                ->textInput(['placeholder' => 'Поиск по наименованию...', 'id' => 'filter-search'])
+                ->label(false) ?>
+        </div>
+        <div class="col-4 d-flex align-items-center gap-3">
+            <div class="d-flex align-items-center gap-2">
+                <label class="mb-0 text-nowrap small fw-semibold">Показывать по:</label>
+                <?= $formFilter->field($filter, 'pageSize', ['options' => ['class' => 'mb-0']])
+                    ->dropDownList([50 => '50', 100 => '100', 500 => '500'], [
+                        'id' => 'filter-page-size',
+                        'class' => 'form-select form-select-sm w-auto',
+                    ])->label(false) ?>
+            </div>
+            <div class="d-flex align-items-center gap-2">
+                <label class="mb-0 text-nowrap small fw-semibold">Сортировка:</label>
+                <?= $formFilter->field($filter, 'sort_by', ['options' => ['class' => 'mb-0']])
+                    ->dropDownList(['name' => 'По наименованию', 'date_actuality' => 'По дате актуальности'], [
+                        'id' => 'filter-sort-by',
+                        'class' => 'form-select form-select-sm w-auto',
+                    ])->label(false) ?>
+            </div>
         </div>
     </div>
 
     <?php ActiveForm::end(); ?>
 
-
-
-
-    <!-- Форма массового обновления актуальности -->
-    <?php $form = ActiveForm::begin(['id' => 'actuality-form']); ?>
-    <div class="form-group d-flex justify-content-end">
-        <?= Html::submitButton('Сохранить', ['class' => 'btn btn-success', 'id' => 'saveButton']) ?>
+    <?php if ($counters !== null): ?>
+    <div class="row g-2 mb-3 mt-2">
+        <div class="col-auto">
+            <div class="card border-0 bg-light px-3 py-2 text-center" style="min-width:110px">
+                <div class="fs-4 fw-bold"><?= $counters['total'] ?></div>
+                <div class="small text-muted">Всего</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 bg-success bg-opacity-10 px-3 py-2 text-center" style="min-width:110px">
+                <div class="fs-4 fw-bold text-success"><?= $counters['active'] ?></div>
+                <div class="small text-muted">Активных</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 bg-warning bg-opacity-10 px-3 py-2 text-center" style="min-width:110px">
+                <div class="fs-4 fw-bold text-warning"><?= $counters['no_labels'] ?></div>
+                <div class="small text-muted">Без матрицы</div>
+            </div>
+        </div>
+        <?php if ($counters['no_score'] !== null): ?>
+        <div class="col-auto">
+            <div class="card border-0 bg-danger bg-opacity-10 px-3 py-2 text-center" style="min-width:110px">
+                <div class="fs-4 fw-bold text-danger"><?= $counters['no_score'] ?></div>
+                <div class="small text-muted">Без оценки</div>
+            </div>
+        </div>
+        <?php endif; ?>
     </div>
+    <?php endif; ?>
+
+    <!-- Bulk-тулбар (скрыт пока нет выбора) -->
+    <div id="bulkToolbar" class="d-none mb-2 p-2 bg-light rounded border d-flex flex-wrap align-items-center gap-2">
+        <span class="fw-semibold text-primary me-1" id="bulkCount">0 выбрано</span>
+        <select id="bulkLabelSelect" class="form-select form-select-sm" style="max-width:220px">
+            <option value="">— Лейбл —</option>
+        </select>
+        <button class="btn btn-sm btn-success" id="bulkAddBtn" title="Добавить лейбл выбранным товарам">
+            <i class="fa fa-plus"></i> Добавить
+        </button>
+        <button class="btn btn-sm btn-outline-danger" id="bulkRemoveBtn" title="Убрать лейбл с выбранных товаров">
+            <i class="fa fa-minus"></i> Убрать
+        </button>
+        <button class="btn btn-sm btn-link text-secondary ms-auto" id="bulkClearBtn">
+            <i class="fa fa-times"></i> Снять выделение
+        </button>
+    </div>
+
     <?= GridView::widget([
-        'dataProvider' => $dataProvider,
-        'responsive' => false,
-        'hover' => true,
-        'floatHeader' => false,
-        'tableOptions' => ['class' => 'table table-bordered'],
-        'containerOptions' => ['style' => 'overflow:auto; max-height:500px;'],
-        'rowOptions' => function($row) {
-            return $row['actuality'] ? ['class'=>'table-success'] : [];
+        'dataProvider'     => $dataProvider,
+        'responsive'       => false,
+        'hover'            => true,
+        'floatHeader'      => false,
+        'tableOptions'     => ['class' => 'table table-bordered'],
+        'containerOptions' => ['style' => 'overflow:auto; max-height:600px;'],
+        'rowOptions'       => function ($row) {
+            $hasActive = false;
+            foreach (($row['actualities'] ?? []) as $act) {
+                if ($act->getStatus() === 'active') {
+                    $hasActive = true;
+                    break;
+                }
+            }
+            return [
+                'data-guid' => $row['product']->id,
+                'class'     => $hasActive ? 'table-success' : '',
+            ];
         },
         'columns' => [
             [
-                'label' => 'Наименование',
-                'format' => 'raw',
-                'contentOptions' => ['style'=>'min-width:150px;'],
-                'value' => function ($row, $key, $index) {
-                    $product = $row['product'];
-                    $name = Html::encode($product->name . ' (' . $product->id . ')');
-                    $btn = Html::button('+ Добавить интервал', [
-                        'class' => 'btn btn-xs btn-outline-primary ms-2 add-actuality-row',
-                        'type' => 'button',
-                        'title' => 'Добавить интервал',
-                        'data-guid' => $product->id,
-                        'data-name' => $product->name,
-                    ]);
-                    return '<div class="d-flex justify-content-between">' . $name . $btn . '</div>';
-                }
+                'format'         => 'raw',
+                'headerOptions'  => ['style' => 'width:36px;text-align:center;vertical-align:middle'],
+                'contentOptions' => ['style' => 'width:36px;text-align:center;vertical-align:middle'],
+                'header'         => '<input type="checkbox" id="selectAllCheckbox" class="form-check-input" title="Выбрать всё на странице">',
+                'value'          => function ($row) {
+                    return '<input type="checkbox" class="form-check-input row-checkbox" value="' . Html::encode($row['product']->id) . '">';
+                },
             ],
             [
-                'label' => 'Актуальность ассортимента',
-                'format' => 'raw',
-                'contentOptions' => ['style'=>'white-space:nowrap; min-width:100px;'],
-                'value' => function ($row, $k, $i) use ($months) {
-                    $product   = $row['product'];
-                    $actuality = $row['actuality'];
-                    $from = $actuality ? substr($actuality->date_from, 0, 7) : null;
-                    $to   = $actuality ? substr($actuality->date_to, 0, 7)   : null;
-                    $clearBtn = $actuality ? '<div class="ms-2 d-flex justify-content-center align-items-center  clear-interval-btn" data-id="' . $actuality->id . '" >
-                        <i class="fa fa-times"></i>
-                    </div>' : '';
-                    $inputs = '<div class="d-flex justify-content-center align-items-center">';
-                    $inputs .= Html::hiddenInput("actuality[$i][guid]", $product->id);
-                    if ($actuality) {
-                        $inputs .= Html::hiddenInput("actuality[$i][id]", $actuality->id);
-                    }
-                    $inputs .= Html::tag('div',
-                        Html::dropDownList("actuality[$i][from]", $from, $months, [
-                            'class'=>'form-select from-month form-select-sm me-1',
-                            'prompt'=>'от',
-                            'data-actuality' => $actuality ? 1 : 0,
-                            'style' => 'width:auto;display:inline-block'
-                        ]) .
-                        Html::dropDownList("actuality[$i][to]", $to, $months, [
-                            'class'=>'form-select to-month form-select-sm',
-                            'prompt'=>'до',
-                            'data-actuality' => $actuality ? 1 : 0,
-                            'style' => 'width:auto;display:inline-block'
-                        ]),
-                        ['class'=>'d-flex align-items-center']
-                    ) . $clearBtn ;
-                    $inputs .= '</div>';
-                    return $inputs;
-                }
+                'label'          => 'Наименование',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'min-width:150px;'],
+                'value'          => function ($row) {
+                    $p = $row['product'];
+                    return Html::encode($p->name . ' (' . $p->id . ')');
+                },
             ],
             [
-                'label' => 'Склад NN',
-                'format' => 'raw',
-                'contentOptions' => ['style'=>'width:60px; text-align:center;'],
-                'value' => function ($m, $k, $i) use ($filter){
-                    return Html::checkbox("actuality[$i][warehouse_nn]", false, [
-
+                'label'          => 'Актуальность ассортимента',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'min-width:200px;'],
+                'value'          => function ($row) {
+                    $p    = $row['product'];
+                    $acts = $row['actualities'];
+                    $chips = '<span class="intervals-chips">' . renderActualityChips($acts) . '</span>';
+                    $btn   = Html::button('<i class="fa fa-pencil"></i>', [
+                        'class'     => 'btn btn-xs btn-outline-secondary ms-2 open-intervals-modal',
+                        'title'     => 'Редактировать интервалы',
+                        'data-guid' => $p->id,
+                        'data-name' => $p->name,
                     ]);
-                }
+                    return '<div class="d-flex align-items-center flex-wrap gap-1">' . $chips . $btn . '</div>';
+                },
             ],
             [
-                'label' => 'Склад MSK',
-                'format' => 'raw',
-                'contentOptions' => ['style'=>'width:60px; text-align:center;'],
-                'value' => function ($m, $k, $i) use ($filter){
-                    return Html::checkbox("actuality[$i][warehouse_msk]", false, [
-
-                    ]);
-                }
+                'label'          => 'Лейблы',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'min-width:160px;'],
+                'value'          => function ($row) {
+                    $p      = $row['product'];
+                    $labels   = $row['labels'] ?? [];
+                    $concepts = $row['concepts'] ?? [];
+                    return '<span class="label-chips-cell">'
+                        . renderLabelChips($labels, $p->id)
+                        . renderConceptChips($concepts)
+                        . '</span>';
+                },
+            ],
+            [
+                'label'          => 'Склад NN',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'width:60px; text-align:center;'],
+                'value'          => fn() => '<input type="checkbox">',
+            ],
+            [
+                'label'          => 'Склад MSK',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'width:60px; text-align:center;'],
+                'value'          => fn() => '<input type="checkbox">',
             ],
             [
-                'label' => 'Поставщик/Плантация',
+                'label'  => 'Поставщик/Плантация',
                 'format' => 'text',
-                'contentOptions' => ['style'=>'min-width:150px;'],
-                'value' => function ($m) {
-                    return '–';
-                }
+                'contentOptions' => ['style' => 'min-width:150px;'],
+                'value'  => fn() => '–',
             ],
         ],
     ]); ?>
 
+</div>
+
+<!-- ==================== Модалка интервалов / лейблов ==================== -->
+<div class="modal fade" id="intervalsModal" tabindex="-1" aria-labelledby="intervalsModalLabel" aria-hidden="true">
+    <div class="modal-dialog modal-lg">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="intervalsModalLabel">
+                    <span id="modalProductName" class="text-primary"></span>
+                </h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+            </div>
+            <div class="modal-body">
+                <ul class="nav nav-tabs mb-3" id="modalTabs" role="tablist">
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link active" id="tab-intervals-btn" data-bs-toggle="tab"
+                                data-bs-target="#tabIntervals" type="button" role="tab">
+                            Интервалы актуальности
+                        </button>
+                    </li>
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link" id="tab-labels-btn" data-bs-toggle="tab"
+                                data-bs-target="#tabLabels" type="button" role="tab">
+                            Лейблы
+                        </button>
+                    </li>
+                    <li class="nav-item" role="presentation">
+                        <button class="nav-link" id="tab-scores-btn" data-bs-toggle="tab"
+                                data-bs-target="#tabScores" type="button" role="tab">
+                            Оценки
+                        </button>
+                    </li>
+                </ul>
+
+                <div class="tab-content">
+                    <!-- Вкладка: Интервалы -->
+                    <div class="tab-pane fade show active" id="tabIntervals" role="tabpanel">
+                        <div id="intervalsTableContainer">
+                            <div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>
+                        </div>
 
+                        <div id="intervalForm" class="mt-3 p-3 border rounded bg-light" style="display:none">
+                            <input type="hidden" id="intervalId">
+                            <div class="row g-2 align-items-end">
+                                <div class="col">
+                                    <label class="form-label mb-1 small">Начало</label>
+                                    <select id="intervalFrom" class="form-select form-select-sm">
+                                        <option value="">— выберите —</option>
+                                    </select>
+                                </div>
+                                <div class="col">
+                                    <label class="form-label mb-1 small">Окончание</label>
+                                    <select id="intervalTo" class="form-select form-select-sm">
+                                        <option value="">— выберите —</option>
+                                    </select>
+                                </div>
+                                <div class="col-auto">
+                                    <button class="btn btn-sm btn-success" id="saveIntervalBtn">Сохранить</button>
+                                    <button class="btn btn-sm btn-secondary ms-1" id="cancelIntervalBtn">Отмена</button>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
 
-    <?php ActiveForm::end(); ?>
-    <script>
-        window.productActualityConfig = {
-            months: <?= json_encode($months, JSON_UNESCAPED_UNICODE) ?>
-        };
-    </script>
+                    <!-- Вкладка: Лейблы -->
+                    <div class="tab-pane fade" id="tabLabels" role="tabpanel">
+                        <div id="labelsContainer">
+                            <div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>
+                        </div>
+                    </div>
 
+                    <!-- Вкладка: Оценки -->
+                    <div class="tab-pane fade" id="tabScores" role="tabpanel">
+                        <!-- Avg store score -->
+                        <div id="avgScoreBar" class="mb-3" style="display:none">
+                            <span class="text-muted small">Средняя оценка магазинов:</span>
+                            <span id="avgScoreValue" class="fw-bold ms-1"></span>
+                            <span class="text-warning ms-1" id="avgScoreStars"></span>
+                        </div>
+
+                        <!-- Accordion маппингов -->
+                        <div id="scoresContainer">
+                            <div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>
+                        </div>
+
+                        <!-- Заглушка: добавить тестовый маппинг (только DEBUG) -->
+                        <div id="testMappingBlock" class="mt-3 p-2 border border-dashed rounded bg-light" style="display:none">
+                            <div class="text-muted small mb-2"><i class="fa fa-flask me-1"></i>Режим разработки — добавить тестовый маппинг</div>
+                            <div class="d-flex gap-2">
+                                <input type="text" id="testMappingName" class="form-control form-control-sm"
+                                       placeholder="Название у поставщика" style="max-width:300px">
+                                <button class="btn btn-sm btn-outline-secondary" id="addTestMappingBtn">Добавить</button>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer justify-content-between">
+                <div>
+                    <button class="btn btn-sm btn-primary" id="addIntervalBtn">
+                        <i class="fa fa-plus"></i> Добавить интервал
+                    </button>
+                    <button class="btn btn-sm btn-success d-none" id="saveLabelsBtn">
+                        <i class="fa fa-save"></i> Сохранить лейблы
+                    </button>
+                </div>
+                <!-- кнопки вкладки Оценки — пустые, всё inline -->
+                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
+            </div>
+        </div>
+    </div>
 </div>
 
+<!-- Toast-контейнер -->
+<div id="toastContainer" class="position-fixed top-0 end-0 p-3" style="z-index:9999"></div>
+
+<script>
+window.productActualityConfig = {
+    months: <?= json_encode($months, JSON_UNESCAPED_UNICODE) ?>,
+    urls: {
+        intervals:      '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-intervals']) ?>',
+        saveInterval:   '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-save-interval']) ?>',
+        deleteInterval: '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-delete']) ?>',
+        labels:          '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-labels']) ?>',
+        saveAssortment:  '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-save-assortment']) ?>',
+        removeLabel:     '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-remove-label']) ?>',
+        scores:          '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-scores']) ?>',
+        saveScore:       '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-save-score']) ?>',
+        addTestMapping:  '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-add-test-mapping']) ?>',
+        exportXlsx:      '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/export-xlsx']) ?>',
+        bulkAssign:      '<?= \yii\helpers\Url::to(['/products1c-nomenclature-actuality/ajax-bulk-assign']) ?>',
+        labelsList:      '<?= \yii\helpers\Url::to(['/assortment-label/ajax-list']) ?>',
+    }
+};
+</script>
diff --git a/erp24/views/products1c-nomenclature-markup/index.php b/erp24/views/products1c-nomenclature-markup/index.php
new file mode 100644 (file)
index 0000000..32f5bc5
--- /dev/null
@@ -0,0 +1,355 @@
+<?php
+
+use app\controllers\Products1cNomenclatureMarkupController as MarkupCtrl;
+use kartik\form\ActiveForm;
+use kartik\grid\GridView;
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\web\View;
+
+/* @var $this yii\web\View */
+/* @var $filter yii\base\DynamicModel */
+/* @var $dataProvider yii\data\ActiveDataProvider */
+/* @var $counters array */
+/* @var $categories array */
+/* @var $speciesList array */
+/* @var array $sorts */
+/* @var array $colors */
+/* @var int $pageSize */
+
+$this->title = 'Разметка номенклатуры';
+$this->params['breadcrumbs'][] = $this->title;
+$this->registerJsFile('/js/products1cNomenclatureMarkup/index.js', ['position' => View::POS_END]);
+
+$statusLabels = MarkupCtrl::statusLabels();
+?>
+
+<div class="products1c-nomenclature-markup-index p-4">
+
+    <h1><?= Html::encode($this->title) ?></h1>
+
+    <!-- Таб-навигация -->
+    <ul class="nav nav-tabs mb-4">
+        <li class="nav-item">
+            <a class="nav-link" href="<?= Url::to(['/products1c-nomenclature-actuality/index']) ?>">
+                <i class="fa fa-calendar-check-o me-1"></i>Актуальность ассортимента
+            </a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link active" href="<?= Url::to(['/products1c-nomenclature-markup/index']) ?>">
+                <i class="fa fa-tag me-1"></i>Разметка
+                <span class="badge bg-danger ms-1"><?= $counters[MarkupCtrl::STATUS_PENDING] ?></span>
+            </a>
+        </li>
+        <li class="nav-item">
+            <a class="nav-link" href="<?= Url::to(['/assortment-label/index']) ?>">
+                <i class="fa fa-th-large me-1"></i>Ассортиментная матрица
+            </a>
+        </li>
+    </ul>
+
+    <!-- Counter cards -->
+    <div class="row g-2 mb-4">
+        <div class="col-auto">
+            <div class="card border-0 px-3 py-2 text-center" style="min-width:130px;border-left:4px solid #6c757d!important;border:1px solid #dee2e6;">
+                <div class="fs-4 fw-bold text-secondary"><?= $counters[MarkupCtrl::STATUS_NOT_NEEDED] ?></div>
+                <div class="small text-muted">Не нужна разметка</div>
+                <div style="font-size:10px;color:#adb5bd;">Аксессуары, упаковка</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 px-3 py-2 text-center" style="min-width:130px;border-left:4px solid #dc3545!important;border:1px solid #dee2e6;">
+                <div class="fs-4 fw-bold text-danger"><?= $counters[MarkupCtrl::STATUS_UNCLASSIFIED] ?></div>
+                <div class="small text-muted">Не размечены</div>
+                <div style="font-size:10px;color:#adb5bd;">Разметка не выполнена</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 px-3 py-2 text-center" style="min-width:130px;border-left:4px solid #ffc107!important;border:1px solid #dee2e6;">
+                <div class="fs-4 fw-bold" style="color:#b86d00;"><?= $counters[MarkupCtrl::STATUS_PENDING] ?></div>
+                <div class="small text-muted">Требуют подтверждения</div>
+                <div style="font-size:10px;color:#adb5bd;">Ожидает проверки КМ</div>
+            </div>
+        </div>
+        <div class="col-auto">
+            <div class="card border-0 px-3 py-2 text-center" style="min-width:130px;border-left:4px solid #198754!important;border:1px solid #dee2e6;">
+                <div class="fs-4 fw-bold text-success"><?= $counters[MarkupCtrl::STATUS_APPROVED] ?></div>
+                <div class="small text-muted">Подтверждены</div>
+                <div style="font-size:10px;color:#adb5bd;">Финализированы</div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Фильтры -->
+    <?php $form = ActiveForm::begin(['method' => 'get', 'action' => ['index'], 'options' => ['class' => 'mb-3']]); ?>
+    <div class="d-flex flex-wrap gap-2 align-items-end">
+        <div>
+            <label class="form-label mb-1 small">Категория</label>
+            <?= $form->field($filter, 'category', ['options' => ['class' => 'mb-0']])
+                ->dropDownList($categories, ['prompt' => 'Все', 'class' => 'form-select form-select-sm'])
+                ->label(false) ?>
+        </div>
+        <div>
+            <label class="form-label mb-1 small">Вид</label>
+            <?= $form->field($filter, 'species', ['options' => ['class' => 'mb-0']])
+                ->dropDownList($speciesList, ['prompt' => 'Все', 'class' => 'form-select form-select-sm'])
+                ->label(false) ?>
+        </div>
+        <div>
+            <label class="form-label mb-1 small">Статус разметки</label>
+            <?= $form->field($filter, 'classification_status', ['options' => ['class' => 'mb-0']])
+                ->dropDownList([
+                    ''                               => 'Все',
+                    MarkupCtrl::STATUS_NOT_NEEDED    => 'Не нужна',
+                    MarkupCtrl::STATUS_UNCLASSIFIED  => 'Не размечены',
+                    MarkupCtrl::STATUS_PENDING       => 'Требуют подтверждения',
+                    MarkupCtrl::STATUS_APPROVED      => 'Подтверждены',
+                ], ['class' => 'form-select form-select-sm'])
+                ->label(false) ?>
+        </div>
+        <div>
+            <label class="form-label mb-1 small">Уверенность</label>
+            <?= $form->field($filter, 'confidence_range', ['options' => ['class' => 'mb-0']])
+                ->dropDownList([
+                    ''       => 'Все',
+                    'high'   => '≥ 90%',
+                    'medium' => '70–89%',
+                    'low'    => '< 70%',
+                ], ['class' => 'form-select form-select-sm'])
+                ->label(false) ?>
+        </div>
+        <div>
+            <label class="form-label mb-1 small">На странице</label>
+            <?= $form->field($filter, 'pageSize', ['options' => ['class' => 'mb-0']])
+                ->dropDownList([50 => '50', 100 => '100', 500 => '500'], ['class' => 'form-select form-select-sm'])
+                ->label(false) ?>
+        </div>
+        <?= Html::submitButton('Применить', ['class' => 'btn btn-primary btn-sm']) ?>
+        <a href="<?= Url::to(['index']) ?>" class="btn btn-link btn-sm text-secondary">Сбросить</a>
+    </div>
+    <?php ActiveForm::end(); ?>
+
+    <!-- Bulk-тулбар -->
+    <div id="markupBulkBar" class="d-none mb-2 p-2 rounded border bg-primary text-white d-flex align-items-center gap-3">
+        <span class="fw-semibold" id="markupBulkCount">0 выбрано</span>
+        <button class="btn btn-sm btn-light" id="markupBulkApproveBtn">
+            <i class="fa fa-check-double me-1"></i>Подтвердить выбранные
+        </button>
+        <button class="btn btn-sm btn-outline-light ms-auto" id="markupBulkClearBtn">
+            <i class="fa fa-times me-1"></i>Снять выделение
+        </button>
+    </div>
+
+    <?= GridView::widget([
+        'dataProvider'     => $dataProvider,
+        'responsive'       => false,
+        'hover'            => true,
+        'floatHeader'      => false,
+        'tableOptions'     => ['class' => 'table table-bordered', 'style' => 'font-size:12px'],
+        'containerOptions' => ['style' => 'overflow:auto; max-height:70vh;'],
+        'rowOptions'       => function ($model) {
+            $status = $model->classification_status ?? 'unclassified';
+            $conf   = (int)$model->confidence;
+            $style  = match (true) {
+                $status === 'not_needed'                   => 'background:#f8f9fa;opacity:.75',
+                $status === 'unclassified'                 => 'background:#fff5f5',
+                $status === 'pending' && $conf >= 90       => '',
+                $status === 'pending' && $conf >= 70       => 'background:#fffbeb',
+                $status === 'pending'                      => 'background:#fff5f5',
+                default                                    => '',
+            };
+            return ['data-guid' => $model->id, 'data-status' => $status, 'style' => $style];
+        },
+        'columns' => [
+            [
+                'format'         => 'raw',
+                'headerOptions'  => ['style' => 'width:36px;text-align:center;vertical-align:middle'],
+                'contentOptions' => ['style' => 'width:36px;text-align:center;vertical-align:middle'],
+                'header'         => '<input type="checkbox" id="markupSelectAll" class="form-check-input" title="Выбрать всё на странице">',
+                'value'          => fn($m) => '<input type="checkbox" class="form-check-input markup-row-cb" value="' . Html::encode($m->id) . '">',
+            ],
+            [
+                'label'          => 'Товар',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'min-width:180px'],
+                'value'          => fn($m) => '<strong>' . Html::encode($m->name) . '</strong><br>'
+                    . '<span class="text-muted" style="font-size:10px">' . Html::encode(substr($m->id, 0, 12)) . '…</span>',
+            ],
+            [
+                'attribute'      => 'category',
+                'label'          => 'Категория',
+                'contentOptions' => ['class' => 'category-cell'],
+            ],
+            [
+                'label'          => 'Вид',
+                'contentOptions' => ['class' => 'species-cell'],
+                'value'          => fn($m) => $m->species ?? '—',
+            ],
+            [
+                'label'          => 'Сорт',
+                'contentOptions' => ['class' => 'sort-cell'],
+                'value'          => fn($m) => $m->sort ?? '—',
+            ],
+            [
+                'label'          => 'Цвет',
+                'contentOptions' => ['class' => 'color-cell'],
+                'value'          => fn($m) => $m->color ?? '—',
+            ],
+            [
+                'label'          => 'Высота',
+                'contentOptions' => ['style' => 'text-align:center', 'class' => 'size-cell'],
+                'value'          => fn($m) => $m->size ? $m->size . ' см' : '—',
+            ],
+            [
+                'label'          => 'Статус',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'text-align:center;white-space:nowrap', 'class' => 'status-cell'],
+                'value'          => function ($m) use ($statusLabels) {
+                    $status = $m->classification_status ?? 'unclassified';
+                    $color  = match ($status) {
+                        'not_needed'   => 'secondary',
+                        'unclassified' => 'danger',
+                        'pending'      => 'warning text-dark',
+                        'approved'     => 'success',
+                        default        => 'secondary',
+                    };
+                    $label = $statusLabels[$status] ?? $status;
+                    $conf  = $m->confidence !== null ? ' · ' . $m->confidence . '%' : '';
+                    return '<span class="badge bg-' . $color . '">' . Html::encode($label . $conf) . '</span>';
+                },
+            ],
+            [
+                'label'          => 'Действие',
+                'format'         => 'raw',
+                'contentOptions' => ['style' => 'text-align:center;white-space:nowrap;width:100px'],
+                'value'          => function ($m) {
+                    $status = $m->classification_status ?? 'unclassified';
+                    $guid   = Html::encode($m->id);
+                    $name   = Html::encode($m->name);
+                    $cat    = Html::encode((string)$m->category);
+                    $sp     = Html::encode((string)$m->species);
+                    $sort   = Html::encode((string)$m->sort);
+                    $color  = Html::encode((string)$m->color);
+                    $size   = (string)($m->size ?? '');
+                    $editBtn = '<button class="btn btn-xs btn-outline-secondary markup-edit-btn me-1" title="Редактировать"'
+                        . ' data-guid="' . $guid . '" data-name="' . $name . '"'
+                        . ' data-category="' . $cat . '" data-species="' . $sp . '"'
+                        . ' data-sort="' . $sort . '" data-color="' . $color . '" data-size="' . $size . '">'
+                        . '<i class="fa fa-pencil"></i></button>';
+                    $approveBtn = $status === 'pending'
+                        ? '<button class="btn btn-xs btn-success markup-approve-btn me-1" title="Подтвердить" data-guid="' . $guid . '">'
+                          . '<i class="fa fa-check"></i></button>'
+                        : '';
+                    $notNeededBtn = in_array($status, ['pending', 'unclassified'], true)
+                        ? '<button class="btn btn-xs btn-outline-danger markup-not-needed-btn" title="Не нужна разметка" data-guid="' . $guid . '">'
+                          . '<i class="fa fa-ban"></i></button>'
+                        : '';
+                    $revertBtn = $status === 'not_needed'
+                        ? '<button class="btn btn-xs btn-outline-secondary markup-revert-btn" title="Вернуть в разметку" data-guid="' . $guid . '">'
+                          . '<i class="fa fa-undo"></i></button>'
+                        : '';
+                    return $approveBtn . $editBtn . $notNeededBtn . $revertBtn;
+                },
+            ],
+        ],
+    ]); ?>
+
+</div>
+
+<!-- Модалка: Причина реверса not_needed → unclassified -->
+<div class="modal fade" id="markupRevertModal" tabindex="-1" aria-labelledby="markupRevertModalLabel" aria-hidden="true">
+    <div class="modal-dialog modal-sm">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="markupRevertModalLabel"><i class="fa fa-undo me-2"></i>Вернуть в разметку</h5>
+                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+            </div>
+            <div class="modal-body">
+                <input type="hidden" id="markupRevertGuid">
+                <div class="mb-2 small text-muted">Укажите причину возврата товара в очередь разметки:</div>
+                <textarea id="markupRevertReason" class="form-control form-control-sm" rows="3" maxlength="500" placeholder="Причина…"></textarea>
+                <div class="invalid-feedback" id="markupRevertReasonError">Причина обязательна</div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+                <button type="button" class="btn btn-primary btn-sm" id="markupRevertSaveBtn">
+                    <i class="fa fa-undo me-1"></i>Вернуть
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- Модалка: Редактировать разметку -->
+<div class="modal fade" id="markupEditModal" tabindex="-1" aria-labelledby="markupEditModalLabel" aria-hidden="true">
+    <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header bg-primary text-white">
+                <h5 class="modal-title" id="markupEditModalLabel"><i class="fa fa-tag me-2"></i>Редактировать разметку</h5>
+                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
+            </div>
+            <div class="modal-body">
+                <input type="hidden" id="markupEditGuid">
+                <div class="p-2 mb-3 rounded" style="background:#f0f4f8">
+                    <strong id="markupEditName" class="text-primary"></strong>
+                </div>
+
+                <div class="mb-3">
+                    <label class="form-label fw-semibold" style="font-size:11px">Категория *</label>
+                    <select id="markupEditCategory" class="form-select form-select-sm">
+                        <option value="">— выберите —</option>
+                    </select>
+                </div>
+                <div class="mb-3">
+                    <label class="form-label fw-semibold" style="font-size:11px">Вид</label>
+                    <select id="markupEditSpecies" class="form-select form-select-sm">
+                        <option value="">—</option>
+                    </select>
+                </div>
+                <div class="mb-3">
+                    <label class="form-label fw-semibold" style="font-size:11px">Сорт</label>
+                    <select id="markupEditSort" class="form-select form-select-sm">
+                        <option value="">—</option>
+                    </select>
+                </div>
+                <div class="row mb-3">
+                    <div class="col-6">
+                        <label class="form-label fw-semibold" style="font-size:11px">Цвет</label>
+                        <select id="markupEditColor" class="form-select form-select-sm">
+                            <option value="">—</option>
+                        </select>
+                    </div>
+                    <div class="col-6">
+                        <label class="form-label fw-semibold" style="font-size:11px">Высота (см)</label>
+                        <input type="number" id="markupEditSize" class="form-control form-control-sm" min="1" max="999" placeholder="—">
+                    </div>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+                <button type="button" class="btn btn-primary btn-sm" id="markupEditSaveBtn">
+                    <i class="fa fa-save me-1"></i>Сохранить и подтвердить
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<!-- Toast -->
+<div id="markupToastContainer" class="position-fixed top-0 end-0 p-3" style="z-index:9999"></div>
+
+<script>
+window.markupConfig = {
+    urls: {
+        approve:       '<?= Url::to(['/products1c-nomenclature-markup/ajax-approve']) ?>',
+        bulkApprove:   '<?= Url::to(['/products1c-nomenclature-markup/ajax-bulk-approve']) ?>',
+        notNeeded:     '<?= Url::to(['/products1c-nomenclature-markup/ajax-mark-not-needed']) ?>',
+        saveMarkup:    '<?= Url::to(['/products1c-nomenclature-markup/ajax-save-markup']) ?>',
+        revertMarkup:  '<?= Url::to(['/products1c-nomenclature-markup/ajax-revert-markup']) ?>',
+    },
+    categories:  <?= json_encode(array_values($categories), JSON_UNESCAPED_UNICODE) ?>,
+    speciesList: <?= json_encode(array_values($speciesList), JSON_UNESCAPED_UNICODE) ?>,
+    sorts:       <?= json_encode($sorts, JSON_UNESCAPED_UNICODE) ?>,
+    colors:      <?= json_encode($colors, JSON_UNESCAPED_UNICODE) ?>,
+};
+</script>
diff --git a/erp24/web/js/assortmentLabel/index.js b/erp24/web/js/assortmentLabel/index.js
new file mode 100644 (file)
index 0000000..59068eb
--- /dev/null
@@ -0,0 +1,260 @@
+document.addEventListener('DOMContentLoaded', () => {
+
+    const cfg  = window.assortmentLabelConfig || {};
+    const urls = cfg.urls || {};
+
+    // ─── Toast ──────────────────────────────────────────────────────────────
+
+    function showToast(message, type = 'danger') {
+        const id  = 'toast-' + Date.now();
+        const cls = type === 'success' ? 'bg-success text-white' : 'bg-danger text-white';
+        const html = `
+            <div id="${id}" class="toast align-items-center ${cls} border-0" role="alert">
+                <div class="d-flex">
+                    <div class="toast-body">${message}</div>
+                    <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
+                </div>
+            </div>`;
+        $('#toastContainer').append(html);
+        const el = document.getElementById(id);
+        const toast = new bootstrap.Toast(el, { delay: 4000 });
+        toast.show();
+        el.addEventListener('hidden.bs.toast', () => el.remove());
+    }
+
+    // ─── Channel type badge ──────────────────────────────────────────────────
+
+    const CHANNEL_CLASS = {
+        offline:     'bg-secondary',
+        online:      'bg-info text-dark',
+        marketplace: 'bg-warning text-dark',
+    };
+
+    function channelBadge(type) {
+        if (!type) return '<span class="text-muted small">—</span>';
+        const cls = CHANNEL_CLASS[type] || 'bg-secondary';
+        return `<span class="badge ${cls}">${type}</span>`;
+    }
+
+    // ─── Render table ────────────────────────────────────────────────────────
+
+    function renderTable(labels) {
+        if (!labels.length) {
+            $('#labelsTableWrap').html('<div class="alert alert-info">Лейблов нет. Создайте первый.</div>');
+            return;
+        }
+
+        const rows = labels.map(l => {
+            const color     = l.color || '#6c757d';
+            const colorDot  = `<span class="d-inline-block rounded-circle me-2"
+                style="width:16px;height:16px;background:${color};vertical-align:middle"></span>`;
+            const icon      = l.icon
+                ? `<i class="fa ${l.icon} me-1"></i><span class="text-muted small">${l.icon}</span>`
+                : '<span class="text-muted small">—</span>';
+            const activeBadge = l.is_active
+                ? '<span class="badge bg-success">активен</span>'
+                : '<span class="badge bg-secondary">неактивен</span>';
+            const toggleTitle = l.is_active ? 'Деактивировать' : 'Активировать';
+            const toggleCls   = l.is_active ? 'btn-outline-warning' : 'btn-outline-success';
+            const toggleIcon  = l.is_active ? 'fa-toggle-on' : 'fa-toggle-off';
+            const deleteCls   = l.product_count > 0 ? 'disabled' : 'btn-outline-danger';
+            const deleteTitle = l.product_count > 0
+                ? `Нельзя удалить: используется у ${l.product_count} товаров`
+                : 'Удалить';
+
+            return `
+                <tr data-id="${l.id}" class="${l.is_active ? '' : 'table-secondary'}">
+                    <td style="width:40px" class="text-center">${colorDot}</td>
+                    <td><strong>${l.name}</strong></td>
+                    <td>${channelBadge(l.channel_type)}</td>
+                    <td>${icon}</td>
+                    <td class="text-center fw-bold">${l.product_count}</td>
+                    <td>${activeBadge}</td>
+                    <td style="white-space:nowrap">
+                        <button class="btn btn-xs btn-outline-primary me-1 edit-label-btn"
+                            title="Редактировать"
+                            data-label='${JSON.stringify(l)}'>
+                            <i class="fa fa-pencil"></i>
+                        </button>
+                        <button class="btn btn-xs ${toggleCls} me-1 toggle-active-btn"
+                            title="${toggleTitle}" data-id="${l.id}">
+                            <i class="fa ${toggleIcon}"></i>
+                        </button>
+                        <button class="btn btn-xs ${deleteCls} delete-label-btn"
+                            title="${deleteTitle}" data-id="${l.id}" data-name="${l.name}">
+                            <i class="fa fa-trash"></i>
+                        </button>
+                    </td>
+                </tr>`;
+        }).join('');
+
+        const html = `
+            <table class="table table-bordered table-hover align-middle">
+                <thead class="table-light sticky-top">
+                    <tr>
+                        <th style="width:40px"></th>
+                        <th>Название</th>
+                        <th>Канал</th>
+                        <th>Иконка</th>
+                        <th class="text-center" title="Кол-во товаров с этим лейблом">Товаров</th>
+                        <th>Статус</th>
+                        <th style="width:100px">Действия</th>
+                    </tr>
+                </thead>
+                <tbody>${rows}</tbody>
+            </table>`;
+
+        $('#labelsTableWrap').html(html);
+    }
+
+    // ─── Load list ───────────────────────────────────────────────────────────
+
+    function loadLabels() {
+        $('#labelsTableWrap').html(
+            '<div class="text-center py-4"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>'
+        );
+        $.get(urls.list, data => {
+            if (data.success) {
+                renderTable(data.labels);
+            } else {
+                $('#labelsTableWrap').html('<div class="alert alert-danger">Ошибка загрузки</div>');
+            }
+        }).fail(() => {
+            $('#labelsTableWrap').html('<div class="alert alert-danger">Ошибка запроса</div>');
+        });
+    }
+
+    loadLabels();
+
+    // ─── Modal helpers ───────────────────────────────────────────────────────
+
+    const $labelModal = $('#labelModal');
+    const $labelId    = $('#labelId');
+
+    function openModal(label = null) {
+        $labelId.val(label ? label.id : '');
+        $('#labelModalTitle').text(label ? 'Редактировать лейбл' : 'Новый лейбл');
+        $('#labelName').val(label ? label.name : '');
+        $('#labelChannelType').val(label ? (label.channel_type || '') : '');
+        const color = label ? (label.color || '#4CAF50') : '#4CAF50';
+        $('#labelColor').val(color);
+        $('#labelColorHex').val(color);
+        $('#labelIcon').val(label ? (label.icon || '') : '');
+        $('#labelIsActive').prop('checked', label ? label.is_active : true);
+        $labelModal.modal('show');
+    }
+
+    // Sync color picker ↔ hex input
+    $('#labelColor').on('input', function () {
+        $('#labelColorHex').val($(this).val());
+    });
+    $('#labelColorHex').on('input', function () {
+        const v = $(this).val();
+        if (/^#[0-9A-Fa-f]{6}$/.test(v)) {
+            $('#labelColor').val(v);
+        }
+    });
+
+    // ─── Open create modal ───────────────────────────────────────────────────
+
+    $('#addLabelBtn').on('click', () => openModal());
+
+    // ─── Edit button ─────────────────────────────────────────────────────────
+
+    $(document).on('click', '.edit-label-btn', function () {
+        const label = $(this).data('label');
+        openModal(typeof label === 'string' ? JSON.parse(label) : label);
+    });
+
+    // ─── Save ────────────────────────────────────────────────────────────────
+
+    $('#saveLabelBtn').on('click', function () {
+        const name = $('#labelName').val().trim();
+        if (!name) {
+            showToast('Введите название лейбла');
+            $('#labelName').focus();
+            return;
+        }
+
+        const color = $('#labelColorHex').val() || $('#labelColor').val();
+
+        const data = {
+            id:           $labelId.val(),
+            name,
+            channel_type: $('#labelChannelType').val(),
+            color:        color || null,
+            icon:         $('#labelIcon').val().trim() || null,
+            is_active:    $('#labelIsActive').is(':checked') ? 1 : 0,
+            _csrf:        yii.getCsrfToken(),
+        };
+
+        $(this).prop('disabled', true).text('Сохранение...');
+
+        $.post(urls.save, data, res => {
+            if (res.success) {
+                $labelModal.modal('hide');
+                showToast(res.message, 'success');
+                loadLabels();
+            } else {
+                showToast(res.message || 'Ошибка сохранения');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        }).always(() => {
+            $('#saveLabelBtn').prop('disabled', false).text('Сохранить');
+        });
+    });
+
+    // ─── Toggle active ───────────────────────────────────────────────────────
+
+    let pendingToggleId = null;
+
+    $(document).on('click', '.toggle-active-btn', function () {
+        const id = $(this).data('id');
+        toggleActive(id, false);
+    });
+
+    function toggleActive(id, force) {
+        $.post(urls.toggleActive, { id, force: force ? 1 : 0, _csrf: yii.getCsrfToken() }, res => {
+            if (res.success) {
+                showToast(res.message, 'success');
+                loadLabels();
+            } else if (res.needs_confirm) {
+                pendingToggleId = id;
+                $('#deactivateMessage').text(res.message);
+                $('#deactivateConfirmModal').modal('show');
+            } else {
+                showToast(res.message || 'Ошибка');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        });
+    }
+
+    $('#confirmDeactivateBtn').on('click', function () {
+        if (!pendingToggleId) return;
+        $('#deactivateConfirmModal').modal('hide');
+        toggleActive(pendingToggleId, true);
+        pendingToggleId = null;
+    });
+
+    // ─── Delete ──────────────────────────────────────────────────────────────
+
+    $(document).on('click', '.delete-label-btn:not(.disabled)', function () {
+        const id   = $(this).data('id');
+        const name = $(this).data('name');
+        if (!confirm(`Удалить лейбл «${name}»?`)) return;
+
+        $.post(urls.delete, { id, _csrf: yii.getCsrfToken() }, res => {
+            if (res.success) {
+                showToast(res.message, 'success');
+                loadLabels();
+            } else {
+                showToast(res.message || 'Ошибка удаления');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        });
+    });
+
+});
index 7247527fc02aaefae010217d4542cfec0341f4cb..09db374109fe10118abdb4d78e49fac98ed515d3 100644 (file)
-document.addEventListener("DOMContentLoaded", () => {
-
-    const monthOptions = Object.entries(window.productActualityConfig.months || {}).map(
-        ([k, v]) => `<option value="${k}">${v}</option>`
-    ).join('');
-
-    const baseMonths = getMonthsMap();
-    const monthsForNewRows = extendMonthsMap({ ...baseMonths }, 0);
-    const monthsForExisting = baseMonths;
-
-    let actualIdx = $('#actuality-form table tbody tr').length || 0;
-
-    $(document).on('click', '.add-actuality-row', function(){
-        const btn   = $(this);
-        const guid  = btn.data('guid');
-        const name  = btn.data('name');
-        const table = $('#actuality-form table');
-        // Все строки для этого товара
-        const $rows = table.find('tbody tr').filter(function(){
-            return $(this).find('input[type=hidden][name*="[guid]"]').val() == guid;
-        });
-        const $lastRow = $rows.last();
-        actualIdx++;
-
-        const newRow = `
-            <tr class="table-info">
-                <td style="min-width:150px;">
-                    <div class="d-flex justify-content-between">${name} (${guid}) <div class="text-danger mx-5">Новая запись. Заполните интервал</div></div>
-                </td>
-                <td>
-                    <input type="hidden" name="actuality[${actualIdx}][guid]" value="${guid}">
-                    <div class="d-flex align-items-center">
-                        <select data-actuality="0" name="actuality[${actualIdx}][from]" class="form-select from-month form-select-sm me-1" style="width:auto;display:inline-block">
-                            <option value="">от</option>${monthOptions}
-                        </select>
-                        <select name="actuality[${actualIdx}][to]" class="form-select to-month form-select-sm" style="width:auto;display:inline-block">
-                            <option value="">до</option>${monthOptions}
-                        </select>
-                    </div>
-                </td>
-                <td style="width:60px;text-align:center;">
-                    <input type="checkbox" name="actuality[${actualIdx}][warehouse_nn]">
-                </td>
-                <td style="width:60px;text-align:center;">
-                    <input type="checkbox" name="actuality[${actualIdx}][warehouse_msk]">
-                </td>
-                <td style="min-width:150px;">–</td>
-            </tr>
-        `;
-
-        let $inserted;
-        if ($lastRow.length) {
-            $lastRow.after(newRow);
-            $inserted = $lastRow.next();
-        } else {
-            table.find('tbody').append(newRow);
-            $inserted = table.find('tbody tr').last();
-        }
+document.addEventListener('DOMContentLoaded', () => {
 
-        applyFromMonthLimits($inserted.find('.from-month'), monthsForNewRows);
-        syncToWithFrom($inserted.find('.from-month'));
-    });
+    // ─── Фильтры ────────────────────────────────────────────────────────────
 
-    $('.from-month').each(function () {
-        const hasActuality = $(this).data('actuality') == 1;
-        if (hasActuality) return;
-        applyFromMonthLimits($(this), monthsForExisting);
+    $('.clear-btn').on('click', function () {
+        $('#' + $(this).data('target')).val('').trigger('change');
     });
-    $('.to-month').each(function(){
-        const hasActuality = $(this).data('actuality') == 1;
-        if (hasActuality) return;
-        applyFromMonthLimits($(this), monthsForExisting);
-    });
-
-    $(document).on('change', '.from-month', function(){
-        syncToWithFrom($(this));
-    });
-
-    $('#filter-date-from').on('change', function(){
-        var from = $(this).val();
-        var to = $('#filter-date-to');
 
-        to.find('option').each(function(){
-            var val = $(this).val();
+    $('#filter-date-from').on('change', function () {
+        const from = $(this).val();
+        const $to  = $('#filter-date-to');
+        $to.find('option').each(function () {
+            const val = $(this).val();
             $(this).toggle(val === '' || !from || val >= from);
         });
-
-        var cur = to.val();
+        const cur = $to.val();
         if (from && (!cur || cur < from)) {
-            if (to.find(`option[value="${from}"]`).length) {
-                to.val(from).trigger('change');
-            } else {
-                var firstVisible = to.find('option').filter(function(){ return $(this).is(':visible') && $(this).val(); }).first().val();
-                if (firstVisible) to.val(firstVisible).trigger('change');
-            }
+            const first = $to.find('option:visible[value!=""]').first().val();
+            if (first) $to.val(first).trigger('change');
         }
     });
 
+    const $onlyActive   = $('#onlyActiveCheckbox');
+    const $onlyInactive = $('#onlyInactiveCheckbox');
 
-    $('.clear-btn').on('click', function(){
-        var target = $(this).data('target');
-        $('#' + target).val(null).trigger('change');
+    $onlyActive.on('change', function () {
+        $onlyInactive.prop('disabled', $(this).is(':checked'));
+        if ($(this).is(':checked')) $onlyInactive.prop('checked', false);
+    });
+    $onlyInactive.on('change', function () {
+        $onlyActive.prop('disabled', $(this).is(':checked'));
+        if ($(this).is(':checked')) $onlyActive.prop('checked', false);
     });
 
-    var $onlyActiveCheckbox = $('#onlyActiveCheckbox');
-    var $onlyInactiveCheckbox = $('#onlyInactiveCheckbox');
+    if ($onlyActive.is(':checked'))   $onlyInactive.prop('disabled', true);
+    if ($onlyInactive.is(':checked')) $onlyActive.prop('disabled', true);
+
+    // ─── Toast ──────────────────────────────────────────────────────────────
+
+    function showToast(message, type = 'danger') {
+        const id  = 'toast-' + Date.now();
+        const cls = type === 'success' ? 'bg-success text-white' : 'bg-danger text-white';
+        const html = `
+            <div id="${id}" class="toast align-items-center ${cls} border-0" role="alert" aria-live="assertive">
+                <div class="d-flex">
+                    <div class="toast-body">${message}</div>
+                    <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
+                </div>
+            </div>`;
+        $('#toastContainer').append(html);
+        const el = document.getElementById(id);
+        const toast = new bootstrap.Toast(el, { delay: 4000 });
+        toast.show();
+        el.addEventListener('hidden.bs.toast', () => el.remove());
+    }
 
-    $onlyActiveCheckbox.change(function() {
-        if ($(this).is(':checked')) {
-            $onlyInactiveCheckbox.prop('checked', false);
-            $onlyInactiveCheckbox.prop('disabled', true);
-        } else {
-            $onlyInactiveCheckbox.prop('disabled', false);
+    // ─── Chips helpers ──────────────────────────────────────────────────────
+
+    const STATUS_CLASS = { active: 'bg-success', future: 'bg-primary', past: 'bg-secondary' };
+
+    function renderChips(intervals) {
+        if (!intervals.length) {
+            return '<span class="text-danger" title="Нет интервалов"><i class="fa fa-exclamation-circle"></i></span>';
         }
-    });
+        const visible = intervals.slice(0, 3);
+        const hidden  = intervals.length - visible.length;
+        let html = visible.map(iv => {
+            const cls   = STATUS_CLASS[iv.status] || 'bg-secondary';
+            const style = iv.status === 'past' ? ' style="text-decoration:line-through"' : '';
+            return `<span class="badge ${cls} me-1"${style}>${iv.label}</span>`;
+        }).join('');
+        if (hidden > 0) html += `<span class="text-muted small ms-1">+${hidden} ещё</span>`;
+        return html;
+    }
 
-    $onlyInactiveCheckbox.change(function() {
-        if ($(this).is(':checked')) {
-            $onlyActiveCheckbox.prop('checked', false);
-            $onlyActiveCheckbox.prop('disabled', true);
-        } else {
-            $onlyActiveCheckbox.prop('disabled', false);
+    function updateRowChips(guid, intervals) {
+        const $row = $(`tr[data-guid="${guid}"]`);
+        $row.find('.intervals-chips').html(renderChips(intervals));
+        const hasActive = intervals.some(iv => iv.status === 'active');
+        $row.toggleClass('table-success', hasActive);
+    }
+
+    function renderLabelChips(labels, guid) {
+        if (!labels.length) return '<span class="text-muted small">—</span>';
+        return '<span class="label-chips-wrap">' + labels.map(l => {
+            const color = l.color || '#6c757d';
+            return `<span class="label-chip d-inline-flex align-items-center me-1 mb-1 badge"
+                style="background-color:${color};cursor:default"
+                data-label-id="${l.id}">
+                ${l.name}
+                <button type="button" class="remove-label-chip btn-close btn-close-white ms-1"
+                    style="font-size:.6rem" aria-label="Убрать"
+                    data-guid="${guid}" data-label-id="${l.id}"></button>
+            </span>`;
+        }).join('') + '</span>';
+    }
+
+    function updateRowLabels(guid, labels) {
+        $(`tr[data-guid="${guid}"] .label-chips-cell`).html(renderLabelChips(labels, guid));
+    }
+
+    // ─── Модалка интервалов ─────────────────────────────────────────────────
+
+    const cfg      = window.productActualityConfig || {};
+    const urls     = cfg.urls || {};
+    const months   = cfg.months || {};
+
+    const $modal          = $('#intervalsModal');
+    const $tableContainer = $('#intervalsTableContainer');
+    const $intervalForm   = $('#intervalForm');
+    const $intervalId     = $('#intervalId');
+    const $fromSel        = $('#intervalFrom');
+    const $toSel          = $('#intervalTo');
+
+    let currentGuid = null;
+    let currentIntervals = [];
+    let currentLabels = [];
+
+    function buildMonthOptions(selectedVal = '') {
+        return Object.entries(months).map(([k, v]) =>
+            `<option value="${k}"${k === selectedVal ? ' selected' : ''}>${v}</option>`
+        ).join('');
+    }
+
+    function initMonthSelects(fromVal = '', toVal = '') {
+        const opts = buildMonthOptions();
+        $fromSel.html('<option value="">— выберите —</option>' + opts).val(fromVal);
+        $toSel.html('<option value="">— выберите —</option>' + opts).val(toVal);
+        filterToOptions();
+    }
+
+    function filterToOptions() {
+        const from = $fromSel.val();
+        $toSel.find('option').each(function () {
+            const v = $(this).val();
+            $(this).toggle(v === '' || !from || v >= from);
+        });
+        if (from && $toSel.val() && $toSel.val() < from) {
+            $toSel.val(from);
         }
-    });
+    }
+
+    $fromSel.on('change', filterToOptions);
 
-    if ($onlyActiveCheckbox.is(':checked')) {
-        $onlyInactiveCheckbox.prop('disabled', true);
-    } else if ($onlyInactiveCheckbox.is(':checked')) {
-        $onlyActiveCheckbox.prop('disabled', true);
+    function statusBadge(status) {
+        const map = { active: 'success', future: 'primary', past: 'secondary' };
+        const labels = { active: 'активный', future: 'будущий', past: 'прошедший' };
+        return `<span class="badge bg-${map[status] || 'secondary'}">${labels[status] || status}</span>`;
     }
 
-    function checkIntervalsForGuid(guid) {
-        // Собираем все интервалы для товара (всех строк)
-        let intervals = [];
-        $('#actuality-form table tbody tr').each(function(){
-            let $row = $(this);
-            let rowGuid = $row.find('input[type=hidden][name*="[guid]"]').val();
-            if (rowGuid == guid) {
-                let from = $row.find('select.from-month').val();
-                let to   = $row.find('select.to-month').val();
-                if (from && to) intervals.push({from, to, $row});
+    function renderIntervalsTable(intervals) {
+        if (!intervals.length) {
+            return '<p class="text-muted">Интервалов нет. Добавьте первый.</p>';
+        }
+        const rows = intervals.map((iv, i) => {
+            const style = iv.status === 'past' ? ' style="text-decoration:line-through;opacity:.7"' : '';
+            return `
+                <tr>
+                    <td class="text-center">${i + 1}</td>
+                    <td${style}>${iv.label}</td>
+                    <td>${statusBadge(iv.status)}</td>
+                    <td>
+                        <button class="btn btn-xs btn-outline-primary edit-interval-btn me-1"
+                            data-id="${iv.id}" data-from="${iv.from_ym}" data-to="${iv.to_ym}">
+                            <i class="fa fa-pencil"></i>
+                        </button>
+                        <button class="btn btn-xs btn-outline-danger delete-interval-btn"
+                            data-id="${iv.id}" data-label="${iv.label}">
+                            <i class="fa fa-trash"></i>
+                        </button>
+                    </td>
+                </tr>`;
+        }).join('');
+
+        return `
+            <table class="table table-sm table-bordered mb-0">
+                <thead class="table-light">
+                    <tr>
+                        <th style="width:40px">#</th>
+                        <th>Период</th>
+                        <th>Статус</th>
+                        <th style="width:80px">Действия</th>
+                    </tr>
+                </thead>
+                <tbody>${rows}</tbody>
+            </table>`;
+    }
+
+    function loadIntervals(guid) {
+        $tableContainer.html('<div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>');
+        $.get(urls.intervals, { guid }, data => {
+            if (data.success) {
+                currentIntervals = data.intervals;
+                $tableContainer.html(renderIntervalsTable(currentIntervals));
+            } else {
+                $tableContainer.html('<div class="alert alert-danger">Ошибка загрузки</div>');
             }
+        }).fail(() => {
+            $tableContainer.html('<div class="alert alert-danger">Ошибка запроса</div>');
         });
-        intervals.sort((a,b) => a.from.localeCompare(b.from));
-
-        let hasOverlap = false;
-        for(let i=0; i<intervals.length; ++i) {
-            for(let j=i+1; j<intervals.length; ++j) {
-                if (intervals[i].to >= intervals[j].from) {
-                    // Пересечение!
-                    intervals[i].$row.addClass('table-danger');
-                    intervals[j].$row.addClass('table-danger');
-                    hasOverlap = true;
-                }
-            }
-        }
-        if (hasOverlap) {
-            if (!$('.interval-overlap-alert').length) {
-                $('<div class="alert alert-warning interval-overlap-alert mt-2">Пересекающиеся диапазоны по одному товару!</div>')
-                    .insertBefore('#actuality-form');
+    }
+
+    function loadLabels(guid) {
+        $('#labelsContainer').html('<div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>');
+        $.get(urls.labels, { guid }, data => {
+            if (!data.success) {
+                $('#labelsContainer').html('<div class="alert alert-danger">Ошибка загрузки</div>');
+                return;
             }
-        } else {
-            $('.interval-overlap-alert').remove();
-            $('#actuality-form table tbody tr').removeClass('table-danger');
-        }
-        return hasOverlap;
+            currentLabels = data.labels.filter(l => l.is_assigned);
+            const rows = data.labels.map(l => {
+                const color   = l.color || '#6c757d';
+                const checked = l.is_assigned ? ' checked' : '';
+                const badge   = `<span class="badge me-2" style="background-color:${color}">&nbsp;</span>`;
+                return `<div class="form-check mb-2">
+                    <input class="form-check-input label-checkbox" type="checkbox" value="${l.id}"
+                        id="lbl-${l.id}"${checked}>
+                    <label class="form-check-label d-flex align-items-center" for="lbl-${l.id}">
+                        ${badge}${l.name}
+                        ${l.channel_type ? `<span class="text-muted small ms-2">(${l.channel_type})</span>` : ''}
+                    </label>
+                </div>`;
+            }).join('');
+            $('#labelsContainer').html(rows || '<p class="text-muted">Нет доступных лейблов</p>');
+        }).fail(() => {
+            $('#labelsContainer').html('<div class="alert alert-danger">Ошибка запроса</div>');
+        });
     }
 
-    $(document).on('change', '.from-month, .to-month', function(){
-        let $row = $(this).closest('tr');
-        let guid = $row.find('input[type=hidden][name*="[guid]"]').val();
-        checkIntervalsForGuid(guid);
+    $('#tab-intervals-btn').on('shown.bs.tab', function () {
+        $('#addIntervalBtn').removeClass('d-none');
+        $('#saveLabelsBtn').addClass('d-none');
     });
 
-    function ymParse(ym) {
-        const [y, m] = ym.split('-').map(Number);
-        return { y, m };
-    }
-    function ymFormat(y, m) {
-        return `${y}-${String(m).padStart(2, '0')}`;
-    }
-    function ymAdd(ym, deltaMonths) {
-        const { y, m } = ymParse(ym);
-        const d = new Date(y, m - 1 + deltaMonths, 1);
-        return ymFormat(d.getFullYear(), d.getMonth() + 1);
+    $('#tab-labels-btn').on('shown.bs.tab', function () {
+        $('#addIntervalBtn').addClass('d-none');
+        $('#saveLabelsBtn').removeClass('d-none');
+        if (currentGuid) loadLabels(currentGuid);
+    });
+
+    $('#tab-scores-btn').on('shown.bs.tab', function () {
+        $('#addIntervalBtn').addClass('d-none');
+        $('#saveLabelsBtn').addClass('d-none');
+        if (currentGuid) loadScores(currentGuid);
+    });
+
+    // ─── Scores tab ──────────────────────────────────────────────────────────
+
+    function starsHtml(score) {
+        if (!score) return '';
+        let html = '';
+        for (let i = 1; i <= 5; i++) {
+            html += `<i class="fa fa-star${i <= score ? '' : '-o'} text-warning"></i>`;
+        }
+        return html;
     }
-    function getMinStartMonthByRule(today = new Date()) {
-        const y = today.getFullYear();
-        const m = today.getMonth(); // 0..11
-        const d = today.getDate();
-        const shift = (d <= 10) ? 1 : 2;          // правило 10/11
-        const min = new Date(y, m + shift, 1);
-        return ymFormat(min.getFullYear(), min.getMonth() + 1);
+
+    function renderStarPicker(mappingId, currentScore) {
+        let html = '<div class="star-picker d-flex gap-1 mb-2" data-mapping-id="' + mappingId + '">';
+        for (let i = 1; i <= 5; i++) {
+            const active = currentScore >= i ? ' text-warning' : ' text-muted';
+            html += `<i class="fa fa-star fa-lg star-btn${active}" style="cursor:pointer" data-value="${i}"></i>`;
+        }
+        html += '<button class="btn btn-xs btn-link text-danger ms-2 clear-score-btn" title="Убрать оценку" data-mapping-id="' + mappingId + '"><i class="fa fa-times"></i></button>';
+        html += '</div>';
+        return html;
     }
 
+    function renderScoreAccordion(mappings, accordionId) {
+        if (!mappings.length) {
+            return '<p class="text-muted">Маппингов нет. <span id="noMappingsHint">Добавьте через справочник поставщиков.</span></p>';
+        }
 
-    function getMonthsMap() {
-        return { ...(window.productActualityConfig?.months || {}) };
+        return mappings.map((m, i) => {
+            const deleted  = !!m.deleted_at;
+            const rowCls   = deleted ? 'opacity-50' : '';
+            const deletedBadge = deleted ? '<span class="badge bg-secondary ms-2">Удалён</span>' : '';
+            const label    = [m.supplier_name, m.plantation_name, m.supplier_product_name].filter(Boolean).join(' · ');
+            const scoreStr = m.score ? starsHtml(m.score) : '<span class="text-muted small">Без оценки</span>';
+            const comment  = m.comment ? `<p class="text-muted small mb-1">${m.comment}</p>` : '';
+            const editedAt = m.km_comment_at ? `<span class="text-muted small">Изменено: ${m.km_comment_at}</span>` : '';
+
+            return `
+            <div class="accordion-item ${rowCls}" data-mapping-id="${m.id}">
+                <h2 class="accordion-header">
+                    <button class="accordion-button collapsed py-2" type="button"
+                            data-bs-toggle="collapse" data-bs-target="#score-collapse-${m.id}">
+                        <span class="me-2">${scoreStr}</span>
+                        <span class="small">${label}</span>${deletedBadge}
+                    </button>
+                </h2>
+                <div id="score-collapse-${m.id}" class="accordion-collapse collapse"
+                     data-bs-parent="#${accordionId}">
+                    <div class="accordion-body py-2">
+                        ${deleted ? '<div class="alert alert-secondary py-1 small mb-2">Маппинг удалён. Оценка сохранена.</div>' : ''}
+                        ${renderStarPicker(m.id, m.score)}
+                        <textarea class="form-control form-control-sm score-comment mb-2"
+                                  rows="2" placeholder="Комментарий КМ..."
+                                  data-mapping-id="${m.id}">${m.comment || ''}</textarea>
+                        <div class="d-flex align-items-center gap-2">
+                            <button class="btn btn-sm btn-primary save-score-btn" data-mapping-id="${m.id}">
+                                Сохранить
+                            </button>
+                            ${editedAt}
+                        </div>
+                    </div>
+                </div>
+            </div>`;
+        }).join('');
     }
 
+    function loadScores(guid) {
+        $('#scoresContainer').html('<div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>');
+        $('#avgScoreBar').hide();
 
-    function extendMonthsMap(monthsMap, nextYears = 1) {
-        const keys = Object.keys(monthsMap).sort(); 
-        if (keys.length === 0) return monthsMap;
+        $.get(urls.scores, { guid }, data => {
+            if (!data.success) {
+                $('#scoresContainer').html('<div class="alert alert-danger">Ошибка загрузки</div>');
+                return;
+            }
+
+            // Avg store score
+            if (data.avg_store_score !== null && data.avg_store_score !== undefined) {
+                $('#avgScoreValue').text(data.avg_store_score);
+                $('#avgScoreStars').html(starsHtml(Math.round(data.avg_store_score)));
+                $('#avgScoreBar').show();
+            }
 
-        const last = keys[keys.length - 1]; 
-        const totalAppend = nextYears * 12;
+            const accId = 'scoresAccordion';
+            const html  = `<div class="accordion accordion-flush" id="${accId}">${renderScoreAccordion(data.mappings, accId)}</div>`;
+            $('#scoresContainer').html(html);
 
-        let cur = last;
-        for (let i = 0; i < totalAppend; i++) {
-            cur = ymAdd(cur, 1);
-            if (!monthsMap[cur]) {
-                monthsMap[cur] = cur.replace('-', '-');
+            // Stub block (только если нет маппингов И это дев-режим)
+            if (data.stub_mode || !data.mappings.length) {
+                $('#testMappingBlock').show();
+            } else {
+                $('#testMappingBlock').hide();
             }
-        }
-        return monthsMap;
+        }).fail(() => {
+            $('#scoresContainer').html('<div class="alert alert-danger">Ошибка запроса</div>');
+        });
     }
 
-    function buildMonthOptions(monthsMap, minYM) {
-        const keys = Object.keys(monthsMap).sort().filter(k => !minYM || k >= minYM);
-        return keys.map(k => `<option value="${k}">${monthsMap[k]}</option>`).join('');
-    }
+    // Star picker interaction
+    $(document).on('click', '.star-btn', function () {
+        const val    = parseInt($(this).data('value'), 10);
+        const $picker = $(this).closest('.star-picker');
+        $picker.find('.star-btn').each(function (i) {
+            $(this).toggleClass('text-warning', i < val).toggleClass('text-muted', i >= val);
+        });
+        $picker.data('selected-score', val);
+    });
 
-    function applyFromMonthLimits(fromSelect, monthsMap) {
-        const minYM = getMinStartMonthByRule();
+    $(document).on('click', '.clear-score-btn', function () {
+        const $picker = $(this).closest('.star-picker');
+        $picker.find('.star-btn').removeClass('text-warning').addClass('text-muted');
+        $picker.data('selected-score', null);
+    });
 
-        const prompt = fromSelect.find('option[value=""]').length ? '<option value="">от</option>' : '';
-        const options = prompt + buildMonthOptions(monthsMap, minYM);
-        const current = fromSelect.val();
+    // Save score
+    $(document).on('click', '.save-score-btn', function () {
+        const mappingId = $(this).data('mapping-id');
+        const $item     = $(`[data-mapping-id="${mappingId}"]`).first().closest('.accordion-item');
+        const $picker   = $item.find('.star-picker');
+        const score     = $picker.data('selected-score') || null;
+        const comment   = $item.find('.score-comment').val().trim();
+
+        $(this).prop('disabled', true).text('...');
+
+        $.post(urls.saveScore, {
+            mapping_id: mappingId,
+            score:      score !== null ? score : '',
+            comment,
+            _csrf: yii.getCsrfToken(),
+        }, res => {
+            if (res.success) {
+                showToast('Сохранено', 'success');
+                loadScores(currentGuid);
+            } else {
+                showToast(res.message || 'Ошибка');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        }).always(() => {
+            $(`.save-score-btn[data-mapping-id="${mappingId}"]`).prop('disabled', false).text('Сохранить');
+        });
+    });
+
+    // Test mapping stub
+    $('#addTestMappingBtn').on('click', function () {
+        const name = $('#testMappingName').val().trim() || 'Тестовый товар';
+        $(this).prop('disabled', true);
+
+        $.post(urls.addTestMapping, {
+            guid: currentGuid,
+            product_name: name,
+            _csrf: yii.getCsrfToken(),
+        }, res => {
+            if (res.success) {
+                showToast(res.message, 'success');
+                loadScores(currentGuid);
+            } else {
+                showToast(res.message || 'Ошибка');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        }).always(() => {
+            $('#addTestMappingBtn').prop('disabled', false);
+        });
+    });
 
-        fromSelect.html(options);
+    $(document).on('click', '.open-intervals-modal', function () {
+        currentGuid = $(this).data('guid');
+        const name  = $(this).data('name');
+        $('#modalProductName').text(name);
+        $intervalForm.hide();
+        $intervalId.val('');
+        // Reset to Intervals tab
+        bootstrap.Tab.getOrCreateInstance(document.getElementById('tab-intervals-btn')).show();
+        $('#addIntervalBtn').removeClass('d-none');
+        $('#saveLabelsBtn').addClass('d-none');
+        $('#scoresContainer').html('<div class="text-center py-3"><span class="spinner-border spinner-border-sm"></span> Загрузка...</div>');
+        $('#avgScoreBar').hide();
+        $('#testMappingBlock').hide();
+        loadIntervals(currentGuid);
+        $modal.modal('show');
+    });
 
-        if (current && current >= minYM) {
-            fromSelect.val(current);
-        } else {
-            fromSelect.val('');
+    $modal.on('hidden.bs.modal', function () {
+        if (currentGuid) {
+            updateRowChips(currentGuid, currentIntervals);
+            updateRowLabels(currentGuid, currentLabels);
         }
+    });
 
-        fromSelect.trigger('change');
-    }
+    $('#saveLabelsBtn').on('click', function () {
+        const labelIds = [];
+        $('#labelsContainer .label-checkbox:checked').each(function () {
+            labelIds.push(parseInt($(this).val(), 10));
+        });
 
-    function syncToWithFrom(fromSelect) {
-        const from = fromSelect.val();
-        const $to = fromSelect.closest('td').find('.to-month');
-        if (!$to.length) return;
+        $(this).prop('disabled', true).text('Сохранение...');
 
-        $to.find('option').each(function(){
-            const val = $(this).val();
-            $(this).toggle(!val || !from || val >= from);
+        $.post(urls.saveAssortment, {
+            guid:      currentGuid,
+            label_ids: labelIds,
+            _csrf:     yii.getCsrfToken(),
+        }, res => {
+            if (res.success) {
+                currentLabels = res.labels;
+                showToast('Лейблы сохранены', 'success');
+            } else {
+                showToast(res.message || 'Ошибка сохранения');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        }).always(() => {
+            $('#saveLabelsBtn').prop('disabled', false).text('Сохранить лейблы');
         });
+    });
 
-        const curTo = $to.val();
-        if (from && (!curTo || curTo < from)) {
-            if ($to.find(`option[value="${from}"]`).length) {
-                $to.val(from).trigger('change');
+    $(document).on('click', '.remove-label-chip', function (e) {
+        e.stopPropagation();
+        const guid    = $(this).data('guid');
+        const labelId = parseInt($(this).data('label-id'), 10);
+        const $wrap   = $(`tr[data-guid="${guid}"] .label-chips-cell`);
+
+        $.post(urls.removeLabel, {
+            guid,
+            label_id: labelId,
+            _csrf: yii.getCsrfToken(),
+        }, res => {
+            if (res.success) {
+                // Remove the chip from the grid immediately
+                $(`tr[data-guid="${guid}"] .label-chip[data-label-id="${labelId}"]`).remove();
+                if (!$wrap.find('.label-chip').length) {
+                    $wrap.html('<span class="text-muted small">—</span>');
+                }
+                // Keep currentLabels in sync if modal is open for same product
+                if (guid === currentGuid) {
+                    currentLabels = currentLabels.filter(l => l.id !== labelId);
+                }
+                showToast('Лейбл убран', 'success');
             } else {
-                const firstVisible = $to.find('option').filter(function(){ return $(this).is(':visible') && $(this).val(); }).first().val();
-                if (firstVisible) $to.val(firstVisible).trigger('change');
+                showToast(res.message || 'Ошибка');
             }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        });
+    });
+
+    $('#addIntervalBtn').on('click', function () {
+        $intervalId.val('');
+        initMonthSelects();
+        $intervalForm.show();
+        $intervalForm[0].scrollIntoView({ behavior: 'smooth' });
+    });
+
+    $(document).on('click', '.edit-interval-btn', function () {
+        const id   = $(this).data('id');
+        const from = $(this).data('from');
+        const to   = $(this).data('to');
+        $intervalId.val(id);
+        initMonthSelects(from, to);
+        $intervalForm.show();
+        $intervalForm[0].scrollIntoView({ behavior: 'smooth' });
+    });
+
+    $('#cancelIntervalBtn').on('click', function () {
+        $intervalForm.hide();
+        $intervalId.val('');
+    });
+
+    $('#saveIntervalBtn').on('click', function () {
+        const id   = $intervalId.val();
+        const from = $fromSel.val();
+        const to   = $toSel.val();
+
+        if (!from || !to) {
+            showToast('Заполните начало и окончание интервала');
+            return;
         }
-    }
 
-});
+        const data = {
+            guid:      currentGuid,
+            date_from: from,
+            date_to:   to,
+            _csrf:     yii.getCsrfToken(),
+        };
+        if (id) data.id = id;
+
+        $(this).prop('disabled', true).text('Сохранение...');
+
+        $.post(urls.saveInterval, data, res => {
+            if (res.success) {
+                showToast('Сохранено', 'success');
+                $intervalForm.hide();
+                $intervalId.val('');
+                loadIntervals(currentGuid);
+            } else {
+                showToast(res.message || 'Ошибка сохранения');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        }).always(() => {
+            $('#saveIntervalBtn').prop('disabled', false).text('Сохранить');
+        });
+    });
+
+    $(document).on('click', '.delete-interval-btn', function () {
+        const id    = $(this).data('id');
+        const label = $(this).data('label');
+        if (!confirm(`Удалить интервал «${label}»?`)) return;
+
+        $.post(urls.deleteInterval, { id, _csrf: yii.getCsrfToken() }, res => {
+            if (res.success) {
+                showToast('Интервал удалён', 'success');
+                loadIntervals(currentGuid);
+            } else {
+                showToast(res.message || 'Ошибка удаления');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        });
+    });
 
+    // ─── Bulk-операции ──────────────────────────────────────────────────────
 
-$(document).on('click', '.clear-interval-btn', function () {
-    let btn = $(this);
-    let id = btn.data('id');
+    const selectedGuids = new Set();
+    let bulkLabelsLoaded = false;
 
-    if (!confirm('Очистить запись интервала?')) {
-        return;
+    function updateBulkToolbar() {
+        const count = selectedGuids.size;
+        const total = $('.row-checkbox').length;
+
+        if (count > 0) {
+            $('#bulkToolbar').removeClass('d-none');
+            $('#bulkCount').text(count + ' выбрано');
+            if (!bulkLabelsLoaded) loadBulkLabels();
+        } else {
+            $('#bulkToolbar').addClass('d-none');
+        }
+
+        const $all = $('#selectAllCheckbox');
+        $all.prop('checked', count > 0 && count === total);
+        $all.prop('indeterminate', count > 0 && count < total);
     }
 
-    $.ajax({
-        url: '/products1c-nomenclature-actuality/ajax-delete',
-        type: 'POST',
-        data: {id: id, _csrf: yii.getCsrfToken()},
-        success: function (response) {
-            if (response.success) {
-                btn.closest('tr, .table-success').removeClass('table-success');
-                const scope = btn.closest('td');
-                const selects = scope.find('select.from-month, select.to-month');
-                selects.each(function () {
-                    const s = $(this);
-
-                    s.prop('selectedIndex', 0).val('');
-
-                    if (s.hasClass('select2-hidden-accessible')) {
-                        s.val(null).trigger('change.select2');
-                    } else {
-                        s.trigger('change');
-                    }
-                });
-                btn.remove();
-                alert(response.message);
+    function loadBulkLabels() {
+        $.get(urls.labelsList, data => {
+            if (!data.success) return;
+            const $sel = $('#bulkLabelSelect');
+            $sel.find('option:not(:first)').remove();
+            data.labels.filter(l => l.is_active).forEach(l => {
+                $sel.append(
+                    $('<option>').val(l.id).text(l.name).attr('data-color', l.color || '')
+                );
+            });
+            bulkLabelsLoaded = true;
+        });
+    }
+
+    $(document).on('change', '.row-checkbox', function () {
+        const guid = $(this).val();
+        if ($(this).is(':checked')) {
+            selectedGuids.add(guid);
+            $(this).closest('tr').addClass('table-active');
+        } else {
+            selectedGuids.delete(guid);
+            $(this).closest('tr').removeClass('table-active');
+        }
+        updateBulkToolbar();
+    });
+
+    $(document).on('change', '#selectAllCheckbox', function () {
+        const checked = $(this).is(':checked');
+        $('.row-checkbox').each(function () {
+            $(this).prop('checked', checked);
+            const guid = $(this).val();
+            if (checked) {
+                selectedGuids.add(guid);
+                $(this).closest('tr').addClass('table-active');
             } else {
-                alert('Ошибка: ' + response.message);
+                selectedGuids.delete(guid);
+                $(this).closest('tr').removeClass('table-active');
             }
-        },
-        error: function () {
-            alert('Произошла ошибка при удалении');
-        }
+        });
+        updateBulkToolbar();
+    });
+
+    $('#bulkClearBtn').on('click', function () {
+        selectedGuids.clear();
+        $('.row-checkbox').prop('checked', false);
+        $('#selectAllCheckbox').prop('checked', false).prop('indeterminate', false);
+        $('tr.table-active').removeClass('table-active');
+        updateBulkToolbar();
     });
+
+    function doBulkAssign(action) {
+        const labelId = parseInt($('#bulkLabelSelect').val(), 10);
+        if (!labelId) {
+            showToast('Выберите лейбл');
+            $('#bulkLabelSelect').focus();
+            return;
+        }
+
+        const guids = [...selectedGuids];
+        $('#bulkAddBtn, #bulkRemoveBtn').prop('disabled', true);
+
+        $.post(urls.bulkAssign, {
+            guids:     guids,
+            label_ids: [labelId],
+            action,
+            _csrf: yii.getCsrfToken(),
+        }, res => {
+            if (res.success) {
+                showToast(`${res.message} (${res.updated_count} товаров)`, 'success');
+                Object.entries(res.results).forEach(([guid, labels]) => {
+                    updateRowLabels(guid, labels);
+                });
+            } else {
+                showToast(res.message || 'Ошибка');
+            }
+        }).fail(() => {
+            showToast('Ошибка запроса');
+        }).always(() => {
+            $('#bulkAddBtn, #bulkRemoveBtn').prop('disabled', false);
+        });
+    }
+
+    $('#bulkAddBtn').on('click',    () => doBulkAssign('add'));
+    $('#bulkRemoveBtn').on('click', () => doBulkAssign('remove'));
+
 });
diff --git a/erp24/web/js/products1cNomenclatureMarkup/index.js b/erp24/web/js/products1cNomenclatureMarkup/index.js
new file mode 100644 (file)
index 0000000..915f7fd
--- /dev/null
@@ -0,0 +1,345 @@
+document.addEventListener('DOMContentLoaded', () => {
+
+    const cfg  = window.markupConfig || {};
+    const urls = cfg.urls || {};
+
+    // ─── Toast ──────────────────────────────────────────────────────────────
+
+    function showToast(message, type = 'danger') {
+        const id  = 'toast-' + Date.now();
+        const cls = type === 'success' ? 'bg-success text-white' : 'bg-danger text-white';
+        const html = `
+            <div id="${id}" class="toast align-items-center ${cls} border-0" role="alert" aria-live="assertive">
+                <div class="d-flex">
+                    <div class="toast-body">${message}</div>
+                    <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
+                </div>
+            </div>`;
+        $('#markupToastContainer').append(html);
+        const el = document.getElementById(id);
+        const toast = new bootstrap.Toast(el, { delay: 4000 });
+        toast.show();
+        el.addEventListener('hidden.bs.toast', () => el.remove());
+    }
+
+    // ─── Row status helpers ─────────────────────────────────────────────────
+
+    const STATUS_BADGE = {
+        not_needed:   'secondary',
+        unclassified: 'danger',
+        pending:      'warning text-dark',
+        approved:     'success',
+    };
+
+    const STATUS_LABEL = {
+        not_needed:   'Не нужна',
+        unclassified: 'Не размечен',
+        pending:      'Подтвердить',
+        approved:     'Подтверждён',
+    };
+
+    const STATUS_ROW_STYLE = {
+        not_needed:   'background:#f8f9fa;opacity:.75',
+        unclassified: 'background:#fff5f5',
+        approved:     '',
+    };
+
+    function rowStyleForPending(conf) {
+        if (conf >= 90) return '';
+        if (conf >= 70) return 'background:#fffbeb';
+        return 'background:#fff5f5';
+    }
+
+    function applyRowStatus($row, status, conf) {
+        $row.attr('data-status', status);
+
+        let style = STATUS_ROW_STYLE[status];
+        if (style === undefined) {
+            style = rowStyleForPending(conf != null ? parseInt(conf, 10) : 0);
+        }
+        $row.attr('style', style);
+
+        const label = STATUS_LABEL[status] || status;
+        const color = STATUS_BADGE[status] || 'secondary';
+        $row.find('.status-cell').html(`<span class="badge bg-${color}">${label}</span>`);
+
+        const guid = $row.attr('data-guid');
+
+        const $approveBtn = $row.find('.markup-approve-btn');
+        const $notBtn     = $row.find('.markup-not-needed-btn');
+
+        if (status === 'pending') {
+            if (!$approveBtn.length) {
+                $row.find('.markup-edit-btn').before(
+                    `<button class="btn btn-xs btn-success markup-approve-btn me-1" title="Подтвердить" data-guid="${guid}"><i class="fa fa-check"></i></button>`
+                );
+            }
+            if (!$notBtn.length) {
+                $row.find('.markup-edit-btn').after(
+                    `<button class="btn btn-xs btn-outline-danger markup-not-needed-btn" title="Не нужна разметка" data-guid="${guid}"><i class="fa fa-ban"></i></button>`
+                );
+            }
+        } else if (status === 'unclassified') {
+            $approveBtn.remove();
+            if (!$notBtn.length) {
+                $row.find('.markup-edit-btn').after(
+                    `<button class="btn btn-xs btn-outline-danger markup-not-needed-btn" title="Не нужна разметка" data-guid="${guid}"><i class="fa fa-ban"></i></button>`
+                );
+            }
+        } else {
+            $approveBtn.remove();
+            $notBtn.remove();
+        }
+    }
+
+    function updateRowFields($row, product) {
+        $row.find('.category-cell').text(product.category || '');
+        $row.find('.species-cell').text(product.species || '—');
+        $row.find('.sort-cell').text(product.sort || '—');
+        $row.find('.color-cell').text(product.color || '—');
+        $row.find('.size-cell').text(product.size ? product.size + ' см' : '—');
+
+        const $editBtn = $row.find('.markup-edit-btn');
+        $editBtn.attr({
+            'data-category': product.category || '',
+            'data-species':  product.species  || '',
+            'data-sort':     product.sort     || '',
+            'data-color':    product.color    || '',
+            'data-size':     product.size     || '',
+        });
+    }
+
+    // ─── Individual: Approve ────────────────────────────────────────────────
+
+    $(document).on('click', '.markup-approve-btn', function () {
+        const $btn  = $(this).prop('disabled', true);
+        const guid  = $btn.attr('data-guid');
+        const $row  = $(`tr[data-guid="${guid}"]`);
+
+        $.post(urls.approve, { guid, _csrf: yii.getCsrfToken() })
+            .done(resp => {
+                if (resp.success) {
+                    applyRowStatus($row, 'approved', null);
+                    showToast('Подтверждено', 'success');
+                } else {
+                    showToast(resp.message || 'Ошибка');
+                    $btn.prop('disabled', false);
+                }
+            })
+            .fail(() => {
+                showToast('Ошибка сети');
+                $btn.prop('disabled', false);
+            });
+    });
+
+    // ─── Individual: Not needed ─────────────────────────────────────────────
+
+    $(document).on('click', '.markup-not-needed-btn', function () {
+        const $btn = $(this).prop('disabled', true);
+        const guid = $btn.attr('data-guid');
+        const $row = $(`tr[data-guid="${guid}"]`);
+
+        $.post(urls.notNeeded, { guid, _csrf: yii.getCsrfToken() })
+            .done(resp => {
+                if (resp.success) {
+                    applyRowStatus($row, 'not_needed', null);
+                    showToast('Помечено: разметка не нужна', 'success');
+                } else {
+                    showToast(resp.message || 'Ошибка');
+                    $btn.prop('disabled', false);
+                }
+            })
+            .fail(() => {
+                showToast('Ошибка сети');
+                $btn.prop('disabled', false);
+            });
+    });
+
+    // ─── Revert not_needed → unclassified ──────────────────────────────────
+
+    $(document).on('click', '.markup-revert-btn', function () {
+        const guid = $(this).attr('data-guid');
+        $('#markupRevertGuid').val(guid);
+        $('#markupRevertReason').val('').removeClass('is-invalid');
+        bootstrap.Modal.getOrCreateInstance(document.getElementById('markupRevertModal')).show();
+        setTimeout(() => $('#markupRevertReason').trigger('focus'), 300);
+    });
+
+    $('#markupRevertSaveBtn').on('click', function () {
+        const reason = $('#markupRevertReason').val().trim();
+        if (!reason) {
+            $('#markupRevertReason').addClass('is-invalid');
+            return;
+        }
+        $('#markupRevertReason').removeClass('is-invalid');
+
+        const $btn = $(this).prop('disabled', true);
+        const guid = $('#markupRevertGuid').val();
+
+        $.post(urls.revertMarkup, { guid, reason, _csrf: yii.getCsrfToken() })
+            .done(resp => {
+                if (resp.success) {
+                    const $row = $(`tr[data-guid="${guid}"]`);
+                    applyRowStatus($row, 'unclassified', null);
+                    bootstrap.Modal.getInstance(document.getElementById('markupRevertModal')).hide();
+                    showToast('Возвращено в разметку', 'success');
+                } else {
+                    showToast(resp.message || 'Ошибка');
+                }
+            })
+            .fail(() => showToast('Ошибка сети'))
+            .always(() => $btn.prop('disabled', false));
+    });
+
+    // ─── Edit modal ─────────────────────────────────────────────────────────
+
+    function populateSelect($sel, values, current) {
+        const first = $sel.find('option:first').clone();
+        $sel.empty().append(first);
+        values.forEach(v => {
+            const $opt = $('<option>').val(v).text(v);
+            if (v === current) $opt.prop('selected', true);
+            $sel.append($opt);
+        });
+        if (current && !values.includes(current)) {
+            $sel.prepend($('<option>').val(current).text(current).prop('selected', true));
+        }
+    }
+
+    $(document).on('click', '.markup-edit-btn', function () {
+        const $btn = $(this);
+
+        $('#markupEditGuid').val($btn.attr('data-guid'));
+        $('#markupEditName').text($btn.attr('data-name') || '');
+
+        populateSelect($('#markupEditCategory'), cfg.categories || [], $btn.attr('data-category') || '');
+        populateSelect($('#markupEditSpecies'),  cfg.speciesList || [], $btn.attr('data-species')  || '');
+        populateSelect($('#markupEditSort'),     cfg.sorts       || [], $btn.attr('data-sort')     || '');
+        populateSelect($('#markupEditColor'),    cfg.colors      || [], $btn.attr('data-color')    || '');
+
+        $('#markupEditSize').val($btn.attr('data-size') || '');
+
+        const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('markupEditModal'));
+        modal.show();
+    });
+
+    $('#markupEditSaveBtn').on('click', function () {
+        const $btn = $(this).prop('disabled', true);
+        const guid = $('#markupEditGuid').val();
+
+        if (!$('#markupEditCategory').val()) {
+            showToast('Категория обязательна');
+            $btn.prop('disabled', false);
+            return;
+        }
+
+        const data = {
+            guid,
+            category: $('#markupEditCategory').val(),
+            species:  $('#markupEditSpecies').val(),
+            sort:     $('#markupEditSort').val(),
+            color:    $('#markupEditColor').val(),
+            size:     $('#markupEditSize').val(),
+            _csrf:    yii.getCsrfToken(),
+        };
+
+        $.post(urls.saveMarkup, data)
+            .done(resp => {
+                if (resp.success) {
+                    const $row = $(`tr[data-guid="${guid}"]`);
+                    updateRowFields($row, resp.product);
+                    applyRowStatus($row, resp.product.classification_status, null);
+                    bootstrap.Modal.getInstance(document.getElementById('markupEditModal')).hide();
+                    showToast('Сохранено и подтверждено', 'success');
+                } else {
+                    showToast(resp.message || 'Ошибка сохранения');
+                }
+            })
+            .fail(() => showToast('Ошибка сети'))
+            .always(() => $btn.prop('disabled', false));
+    });
+
+    // ─── Bulk selection ─────────────────────────────────────────────────────
+
+    const selectedGuids = new Set();
+
+    function updateMarkupBulkBar() {
+        const count = selectedGuids.size;
+        const $bar  = $('#markupBulkBar');
+
+        if (count > 0) {
+            $bar.removeClass('d-none');
+        } else {
+            $bar.addClass('d-none');
+        }
+
+        $('#markupBulkCount').text(count + ' выбрано');
+
+        const allCbs    = document.querySelectorAll('.markup-row-cb');
+        const checkedCbs = document.querySelectorAll('.markup-row-cb:checked');
+        const $selectAll = $('#markupSelectAll')[0];
+        if ($selectAll) {
+            $selectAll.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length;
+            $selectAll.checked       = allCbs.length > 0 && checkedCbs.length === allCbs.length;
+        }
+    }
+
+    $(document).on('change', '.markup-row-cb', function () {
+        const guid = $(this).val();
+        if (this.checked) {
+            selectedGuids.add(guid);
+        } else {
+            selectedGuids.delete(guid);
+        }
+        updateMarkupBulkBar();
+    });
+
+    $('#markupSelectAll').on('change', function () {
+        const checked = this.checked;
+        $('.markup-row-cb').each(function () {
+            this.checked = checked;
+            if (checked) {
+                selectedGuids.add($(this).val());
+            } else {
+                selectedGuids.delete($(this).val());
+            }
+        });
+        updateMarkupBulkBar();
+    });
+
+    $('#markupBulkClearBtn').on('click', () => {
+        selectedGuids.clear();
+        $('.markup-row-cb, #markupSelectAll').prop('checked', false);
+        updateMarkupBulkBar();
+    });
+
+    // ─── Bulk approve ───────────────────────────────────────────────────────
+
+    $('#markupBulkApproveBtn').on('click', function () {
+        if (!selectedGuids.size) return;
+
+        const $btn = $(this).prop('disabled', true);
+        const guids = Array.from(selectedGuids);
+
+        $.post(urls.bulkApprove, { guids, _csrf: yii.getCsrfToken() })
+            .done(resp => {
+                if (resp.success) {
+                    guids.forEach(guid => {
+                        const $row = $(`tr[data-guid="${guid}"]`);
+                        if ($row.attr('data-status') === 'pending') {
+                            applyRowStatus($row, 'approved', null);
+                        }
+                    });
+                    selectedGuids.clear();
+                    $('.markup-row-cb, #markupSelectAll').prop('checked', false);
+                    updateMarkupBulkBar();
+                    showToast(resp.message || 'Готово', 'success');
+                } else {
+                    showToast(resp.message || 'Ошибка');
+                }
+            })
+            .fail(() => showToast('Ошибка сети'))
+            .always(() => $btn.prop('disabled', false));
+    });
+
+});