]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Правки
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 14 May 2026 06:55:05 +0000 (09:55 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Thu, 14 May 2026 06:55:05 +0000 (09:55 +0300)
14 files changed:
erp24/controllers/Products1cNomenclatureActualityController.php
erp24/controllers/Products1cNomenclatureMarkupController.php
erp24/migrations/m260506_120000_add_automark_rbac_permissions.php
erp24/records/AdminGroup.php
erp24/records/Products1cNomenclature.php
erp24/services/AutoMarkService.php
erp24/traits/AuditableTrait.php
erp24/views/assortment-label/index.php
erp24/views/products1c-nomenclature-actuality/index.php
erp24/views/products1c-nomenclature-markup/index.php
erp24/web/css/select2-container-and-other-styles-fix.css
erp24/web/js/assortmentLabel/index.js
erp24/web/js/products1cNomenclatureActuality/index.js
erp24/web/js/products1cNomenclatureMarkup/index.js

index a1517ad354bc2f56bf125a1c8d377a86bf6333e8..71d086591ad745f527e28aff206e3e34d24b23b9 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace app\controllers;
 
 use Yii;
@@ -8,18 +10,17 @@ 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\AuditLog;
 use yii_app\records\StoreProductRating;
 
 /**
@@ -55,7 +56,7 @@ class Products1cNomenclatureActualityController extends Controller
                     ],
                 ],
                 'verbs' => [
-                    'class' => VerbFilter::className(),
+                    'class' => VerbFilter::class,
                     'actions' => [
                         'delete'                => ['POST'],
                         'ajax-save-interval'    => ['POST'],
@@ -124,7 +125,8 @@ class Products1cNomenclatureActualityController extends Controller
             $filter->search,
         ]);
         if ($filtersUsed) {
-            $query = Products1cNomenclature::find()->alias('n');
+            $query = Products1cNomenclature::find()->alias('n')
+                ->andWhere(['not in', 'n.category', Products1cNomenclature::EXCLUDED_CATEGORIES]);
 
             foreach (['category', 'subcategory', 'species'] as $attr) {
                 if ($filter->$attr) {
@@ -295,7 +297,9 @@ class Products1cNomenclatureActualityController extends Controller
             ]);
         }
 
-        $categories = Products1cNomenclature::find()->select('category')->distinct()->column();
+        $categories = Products1cNomenclature::find()->select('category')->distinct()
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->column();
         $subcategories = Products1cNomenclature::find()->select('subcategory')->distinct();
         $subcategories->andWhere(['not', ['subcategory' => null]]);
         $subcategories->andWhere(['<>', 'subcategory', '']);
@@ -329,6 +333,87 @@ class Products1cNomenclatureActualityController extends Controller
                 ->column();
         }
 
+        $subByCatRows = Products1cNomenclature::find()
+            ->select(['category', 'subcategory'])->distinct()
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->andWhere(['not', ['subcategory' => null]])->andWhere(['<>', 'subcategory', ''])
+            ->orderBy(['category' => SORT_ASC, 'subcategory' => SORT_ASC])
+            ->asArray()->all();
+        $subcategoriesByCategory = [];
+        foreach ($subByCatRows as $row) {
+            $subcategoriesByCategory[$row['category']][] = $row['subcategory'];
+        }
+
+        $specBySubRows = Products1cNomenclature::find()
+            ->select(['subcategory', 'species'])->distinct()
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->andWhere(['not', ['subcategory' => null]])->andWhere(['<>', 'subcategory', ''])
+            ->andWhere(['not', ['species' => null]])->andWhere(['<>', 'species', ''])
+            ->orderBy(['subcategory' => SORT_ASC, 'species' => SORT_ASC])
+            ->asArray()->all();
+        $speciesBySubcategory = [];
+        foreach ($specBySubRows as $row) {
+            $speciesBySubcategory[$row['subcategory']][] = $row['species'];
+        }
+
+        // Build cascade maps for sort/color/size by species/subcategory/category
+        $sortPropNames  = ['sort', 'сорт'];
+        $colorPropNames = ['цвет', 'color'];
+        $sizePropNames  = ['size', 'размер'];
+
+        $attrRows = (new Query())
+            ->select(['n.category', 'n.subcategory', 'n.species', 'ppt.name as prop_name', 'pac.value'])
+            ->from(['n' => Products1cNomenclature::tableName()])
+            ->innerJoin(['pac' => 'products_1c_additional_characteristics'], 'pac.product_id = n.id')
+            ->innerJoin(['ppt' => 'products_1c_prop_type'], 'ppt.id = pac.property_id')
+            ->andWhere(['not in', 'n.category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->andWhere(['in', 'ppt.name', array_merge($sortPropNames, $colorPropNames, $sizePropNames)])
+            ->andWhere(['not', ['pac.value' => null]])
+            ->andWhere(['<>', 'pac.value', ''])
+            ->distinct()
+            ->all();
+
+        $sortsBySpecies      = $sortsBySubcategory      = $sortsByCategory      = [];
+        $colorsBySpecies     = $colorsBySubcategory     = $colorsByCategory     = [];
+        $sizesBySpecies      = $sizesBySubcategory      = $sizesByCategory      = [];
+        $sortSet  = array_fill_keys($sortPropNames,  true);
+        $colorSet = array_fill_keys($colorPropNames, true);
+        $sizeSet  = array_fill_keys($sizePropNames,  true);
+
+        foreach ($attrRows as $row) {
+            $name  = strtolower((string)$row['prop_name']);
+            $value = (string)$row['value'];
+            $cat   = (string)$row['category'];
+            $sub   = (string)($row['subcategory'] ?? '');
+            $sp    = (string)($row['species'] ?? '');
+
+            if (isset($sortSet[$name])) {
+                if ($sp)  $sortsBySpecies[$sp][]      = $value;
+                if ($sub) $sortsBySubcategory[$sub][] = $value;
+                if ($cat) $sortsByCategory[$cat][]    = $value;
+            } elseif (isset($colorSet[$name])) {
+                if ($sp)  $colorsBySpecies[$sp][]      = $value;
+                if ($sub) $colorsBySubcategory[$sub][] = $value;
+                if ($cat) $colorsByCategory[$cat][]    = $value;
+            } elseif (isset($sizeSet[$name])) {
+                if ($sp)  $sizesBySpecies[$sp][]      = $value;
+                if ($sub) $sizesBySubcategory[$sub][] = $value;
+                if ($cat) $sizesByCategory[$cat][]    = $value;
+            }
+        }
+
+        foreach ([
+            &$sortsBySpecies,  &$sortsBySubcategory,  &$sortsByCategory,
+            &$colorsBySpecies, &$colorsBySubcategory, &$colorsByCategory,
+            &$sizesBySpecies,  &$sizesBySubcategory,  &$sizesByCategory,
+        ] as &$attrMap2) {
+            foreach ($attrMap2 as &$vals) {
+                $vals = array_values(array_unique($vals));
+                sort($vals, SORT_NATURAL);
+            }
+        }
+        unset($attrMap2, $vals);
+
         $pendingMarkupCount = null;
         if (Yii::$app->db->getTableSchema('products_1c_nomenclature') !== null) {
             $pendingMarkupCount = Products1cNomenclature::find()
@@ -337,11 +422,22 @@ class Products1cNomenclatureActualityController extends Controller
         }
 
         return $this->render('index', [
-            'filter'             => $filter,
-            'dataProvider'       => $dataProvider,
-            'categories'         => array_combine($categories, $categories),
-            'subcategories'      => array_combine($subcategories, $subcategories),
-            'species'            => array_combine($species, $species),
+            'filter'                  => $filter,
+            'dataProvider'            => $dataProvider,
+            'categories'              => array_combine($categories, $categories),
+            'subcategories'           => array_combine($subcategories, $subcategories),
+            'species'                 => array_combine($species, $species),
+            'subcategoriesByCategory' => $subcategoriesByCategory,
+            'speciesBySubcategory'    => $speciesBySubcategory,
+            'sortsBySpecies'          => $sortsBySpecies,
+            'sortsBySubcategory'      => $sortsBySubcategory,
+            'sortsByCategory'         => $sortsByCategory,
+            'colorsBySpecies'         => $colorsBySpecies,
+            'colorsBySubcategory'     => $colorsBySubcategory,
+            'colorsByCategory'        => $colorsByCategory,
+            'sizesBySpecies'          => $sizesBySpecies,
+            'sizesBySubcategory'      => $sizesBySubcategory,
+            'sizesByCategory'         => $sizesByCategory,
             'types'              => array_combine($lists['type'], $lists['type']),
             'colors'             => array_combine($lists['color'], $lists['color']),
             'sorts'              => array_combine($lists['sort'], $lists['sort']),
@@ -375,7 +471,8 @@ class Products1cNomenclatureActualityController extends Controller
             'size' => ['size', 'размер'],
         ];
 
-        $query = Products1cNomenclature::find()->alias('n');
+        $query = Products1cNomenclature::find()->alias('n')
+            ->andWhere(['not in', 'n.category', Products1cNomenclature::EXCLUDED_CATEGORIES]);
 
         foreach (['category', 'subcategory', 'species'] as $attr) {
             if ($filter->$attr) { $query->andWhere(["n.$attr" => $filter->$attr]); }
@@ -419,7 +516,7 @@ class Products1cNomenclatureActualityController extends Controller
         }
 
         $query->with(['actualities' => fn($q) => $q->orderBy(['date_from' => SORT_ASC])]);
-        $products = $query->orderBy(['n.name' => SORT_ASC])->all();
+        $products = $query->orderBy(['n.name' => SORT_ASC])->limit(10000)->all();
 
         if (!empty($products)) {
             $guids = array_column(array_map(static fn($p) => ['id' => $p->id], $products), 'id');
@@ -436,8 +533,9 @@ class Products1cNomenclatureActualityController extends Controller
 
         $headers = ['Наименование', 'GUID', 'Интервалы актуальности', 'Лейблы', 'Статус'];
         foreach ($headers as $col => $title) {
-            $sheet->setCellValueByColumnAndRow($col + 1, 1, $title);
-            $sheet->getColumnDimensionByColumn($col + 1)->setAutoSize(true);
+            $colLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col + 1);
+            $sheet->setCellValue($colLetter . '1', $title);
+            $sheet->getColumnDimension($colLetter)->setAutoSize(true);
         }
         $sheet->getStyle('A1:E1')->getFont()->setBold(true);
 
@@ -454,11 +552,11 @@ class Products1cNomenclatureActualityController extends Controller
             }
             $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 ? 'Активен' : 'Не активен');
+            $sheet->setCellValue('A' . $rowNum, $product->name);
+            $sheet->setCellValue('B' . $rowNum, $product->id);
+            $sheet->setCellValue('C' . $rowNum, $intervalsText);
+            $sheet->setCellValue('D' . $rowNum, $labelsText);
+            $sheet->setCellValue('E' . $rowNum, $hasActive ? 'Активен' : 'Не активен');
             $rowNum++;
         }
 
@@ -549,12 +647,14 @@ class Products1cNomenclatureActualityController extends Controller
      * Закрываем старую запись и создаем новую при изменении диапазона.
      * @param array $post
      */
-    protected function processBatchActuality(array $post)
+    protected function processBatchActuality(array $post): void
     {
         $userId = Yii::$app->user->id;
         $now = date('Y-m-d H:i:s');
 
-        foreach ($post as $row) {
+        $transaction = Yii::$app->db->beginTransaction();
+        try {
+            foreach ($post as $row) {
             if (empty($row['from']) || empty($row['to'])) {
                 continue;
             }
@@ -750,6 +850,12 @@ class Products1cNomenclatureActualityController extends Controller
                 }
             }
         }
+            $transaction->commit();
+        } catch (\Throwable $e) {
+            $transaction->rollBack();
+            Yii::error('processBatchActuality failed: ' . $e->getMessage(), 'actuality');
+            throw $e;
+        }
     }
 
     public function actionAjaxIntervals(): array
@@ -993,7 +1099,16 @@ class Products1cNomenclatureActualityController extends Controller
                     }
                 }
             } else {
-                ProductAssortment::deleteAll(['product_guid' => $guids, 'label_id' => $labelIds]);
+                $deleted = ProductAssortment::deleteAll(['product_guid' => $guids, 'label_id' => $labelIds]);
+                if ($deleted > 0) {
+                    AuditLog::write(
+                        ProductAssortment::tableName(),
+                        null,
+                        AuditLog::ACTION_BULK_UPDATE,
+                        ['product_guids' => $guids, 'label_ids' => $labelIds],
+                        ['action' => 'bulk_remove_labels', 'deleted' => $deleted],
+                    );
+                }
             }
 
             $transaction->commit();
@@ -1035,25 +1150,25 @@ class Products1cNomenclatureActualityController extends Controller
             throw new BadRequestHttpException('Missing guid');
         }
 
-        $mappingsTableExists = Yii::$app->db->getTableSchema('erp24.product_mappings') !== null;
-
-        if (!$mappingsTableExists) {
+        $mappingsSchema = Yii::$app->db->getTableSchema('erp24.product_mappings');
+        if ($mappingsSchema === null) {
             return ['success' => true, 'mappings' => [], 'avg_store_score' => null, 'stub_mode' => true];
         }
 
+        $hasDeletedAt = isset($mappingsSchema->columns['deleted_at']);
+
+        $select = ['pm.id', 'pm.supplier_product_name', 's.name AS supplier_name', 'pl.name AS plantation_name'];
+        if ($hasDeletedAt) {
+            $select[] = 'pm.deleted_at';
+        }
+
         $mappings = (new Query())
-            ->select([
-                'pm.id',
-                'pm.supplier_product_name',
-                'pm.deleted_at',
-                's.name AS supplier_name',
-                'pl.name AS plantation_name',
-            ])
+            ->select($select)
             ->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])
+            ->orderBy($hasDeletedAt ? ['pm.deleted_at' => SORT_ASC, 'pm.id' => SORT_ASC] : ['pm.id' => SORT_ASC])
             ->all();
 
         $mappingIds = array_column($mappings, 'id');
@@ -1062,7 +1177,8 @@ class Products1cNomenclatureActualityController extends Controller
             ? ProductScore::find()->where(['mapping_id' => $mappingIds])->indexBy('mapping_id')->all()
             : [];
 
-        $avgStoreScore = StoreProductRating::avgForProduct($guid);
+        $storeRatingsExist = Yii::$app->db->getTableSchema(StoreProductRating::tableName()) !== null;
+        $avgStoreScore = $storeRatingsExist ? StoreProductRating::avgForProduct($guid) : null;
 
         return [
             'success'         => true,
@@ -1071,14 +1187,14 @@ class Products1cNomenclatureActualityController extends Controller
                 /** @var ProductScore|null $score */
                 $score = $scores[$m['id']] ?? null;
                 return [
-                    'id'                   => $m['id'],
-                    'supplier_name'        => $m['supplier_name'] ?? '—',
-                    'plantation_name'      => $m['plantation_name'],
+                    '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,
+                    'deleted_at'            => $m['deleted_at'] ?? null,
+                    'score'                 => $score ? $score->score : null,
+                    'comment'               => $score ? $score->comment : null,
+                    'km_comment_at'         => $score ? $score->km_comment_at : null,
                 ];
             }, $mappings),
         ];
@@ -1244,7 +1360,7 @@ class Products1cNomenclatureActualityController extends Controller
         Yii::$app->response->format = Response::FORMAT_JSON;
 
         $request = Yii::$app->request;
-        $id = $id ?? $request->post('id') ?? $request->get('id');
+        $id = $request->post('id') ?? $request->get('id');
 
         if (empty($id)) {
             throw new BadRequestHttpException('Missing parameter: id');
index 1e832906162bb453dc7bdf81cdbdb8182233a9f9..1b0b1796d3e5bb083188886b5fe99faa6b5503ad 100644 (file)
@@ -14,22 +14,26 @@ use yii\web\Controller;
 use yii\web\NotFoundHttpException;
 use yii\web\Response;
 use yii_app\records\AuditLog;
+use yii_app\records\Products1c;
+use yii_app\records\Products1cAutomarkPrediction;
 use yii_app\records\Products1cNomenclature;
+use yii_app\services\AutoMarkService;
 
 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 const STATUS_NOT_NEEDED       = 'not_needed';
+    public const STATUS_APPROVED         = 'approved';
+    public const STATUS_AUTOMARK_PENDING = 'automark_pending';
+    public const STATUS_UNCLASSIFIED     = 'unclassified';
+    public const STATUS_PENDING          = 'pending'; // legacy: может приходить из 1С
 
     public static function statusLabels(): array
     {
         return [
-            self::STATUS_NOT_NEEDED   => 'Не нужна',
-            self::STATUS_UNCLASSIFIED => 'Не размечен',
-            self::STATUS_PENDING      => 'Подтвердить',
-            self::STATUS_APPROVED     => 'Подтверждён',
+            self::STATUS_NOT_NEEDED       => 'Не нужна разметка',
+            self::STATUS_AUTOMARK_PENDING => 'Ожидает подтверждения',
+            self::STATUS_APPROVED         => 'Подтверждены',
+            self::STATUS_UNCLASSIFIED     => 'Не размечены',
         ];
     }
 
@@ -41,7 +45,11 @@ class Products1cNomenclatureMarkupController extends Controller
                 'rules' => [
                     [
                         'allow'       => true,
-                        'actions'     => ['ajax-approve', 'ajax-bulk-approve', 'ajax-mark-not-needed', 'ajax-save-markup', 'ajax-revert-markup'],
+                        'actions'     => [
+                            'ajax-approve', 'ajax-bulk-approve',
+                            'ajax-mark-not-needed', 'ajax-save-markup', 'ajax-revert-markup',
+                            'ajax-approve-prediction', 'ajax-save-prediction', 'ajax-bulk-approve-predictions',
+                        ],
                         'permissions' => ['catmanager/manage'],
                     ],
                     [
@@ -54,11 +62,14 @@ class Products1cNomenclatureMarkupController extends Controller
             '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'],
+                    'ajax-approve'                  => ['POST'],
+                    'ajax-bulk-approve'             => ['POST'],
+                    'ajax-mark-not-needed'          => ['POST'],
+                    'ajax-save-markup'              => ['POST'],
+                    'ajax-revert-markup'            => ['POST'],
+                    'ajax-approve-prediction'       => ['POST'],
+                    'ajax-save-prediction'          => ['POST'],
+                    'ajax-bulk-approve-predictions' => ['POST'],
                 ],
             ],
         ]);
@@ -67,67 +78,84 @@ class Products1cNomenclatureMarkupController extends Controller
     public function actionIndex(): string
     {
         $filter = new DynamicModel([
-            'category', 'species', 'classification_status', 'confidence_range', 'pageSize',
+            'category', 'subcategory', 'species', 'classification_status', 'confidence_range', 'pageSize',
         ]);
-        $filter->addRule(['category', 'species', 'classification_status', 'confidence_range'], 'safe');
+        $filter->addRule(['category', 'subcategory', '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]);
-        }
+        $validStatuses = [
+            self::STATUS_NOT_NEEDED, self::STATUS_APPROVED,
+            self::STATUS_AUTOMARK_PENDING, self::STATUS_UNCLASSIFIED,
+        ];
+        $activeStatus = in_array($filter->classification_status, $validStatuses, true)
+            ? $filter->classification_status
+            : self::STATUS_AUTOMARK_PENDING;
+
+        // ── Счётчики из разных источников ─────────────────────────────────────
+
+        $counters = [
+            // Все товары в номенклатуре 1С (уже размечены: из 1С + одобренные через automark)
+            self::STATUS_NOT_NEEDED       => (int)Products1cNomenclature::find()
+                ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+                ->count(),
+
+            self::STATUS_AUTOMARK_PENDING => (int)Products1cAutomarkPrediction::find()
+                ->where(['status' => Products1cAutomarkPrediction::STATUS_PENDING])
+                ->count(),
+
+            // Товары, одобренные KM через automark (STATUS_APPROVED в таблице предсказаний)
+            self::STATUS_APPROVED         => (int)Products1cAutomarkPrediction::find()
+                ->where(['status' => Products1cAutomarkPrediction::STATUS_APPROVED])
+                ->count(),
+
+            self::STATUS_UNCLASSIFIED     => (int)Products1c::find()
+                ->leftJoin('products_1c_nomenclature pnc', 'products_1c.id = pnc.id')
+                ->leftJoin('products_1c_automark_predictions pap', 'products_1c.id = pap.product_id')
+                ->where(['pnc.id' => null])
+                ->andWhere(['pap.id' => null])
+                ->andWhere(['products_1c.tip' => Products1c::TYPE_PRODUCTS])
+                ->andWhere(['OR',
+                    ['products_1c.type' => null],
+                    ['not in', 'products_1c.type', AutoMarkService::EXCLUDED_TYPES],
+                ])
+                ->count('products_1c.id'),
+        ];
 
-        // 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'];
-            }
-        }
+        // ── Данные для грида (источник зависит от activeStatus) ───────────────
 
-        // 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]);
+        [$dataSource, $dataProvider] = $this->buildDataProvider($filter, $activeStatus, $pageSize);
 
-        $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()
+        $categories = array_filter(
+            Products1cNomenclature::find()->select('category')->distinct()
+                ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+                ->orderBy('category')->column()
         );
+
+        $subcategoryQuery = Products1cNomenclature::find()->select('subcategory')->distinct()
+            ->andWhere(['not', ['subcategory' => null]])
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->orderBy('subcategory');
+        if ($filter->category) {
+            $subcategoryQuery->andWhere(['category' => $filter->category]);
+        }
+        $subcategoryList = array_filter($subcategoryQuery->column());
+
+        $speciesQuery = Products1cNomenclature::find()->select('species')->distinct()
+            ->andWhere(['not', ['species' => null]])
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->orderBy('species');
+        if ($filter->subcategory) {
+            $speciesQuery->andWhere(['subcategory' => $filter->subcategory]);
+        } elseif ($filter->category) {
+            $speciesQuery->andWhere(['category' => $filter->category]);
+        }
+        $speciesList = array_filter($speciesQuery->column());
+
         $sorts = array_filter(
             Products1cNomenclature::find()->select('sort')->distinct()
                 ->andWhere(['not', ['sort' => null]])->orderBy('sort')->column()
@@ -137,18 +165,203 @@ class Products1cNomenclatureMarkupController extends Controller
                 ->andWhere(['not', ['color' => null]])->orderBy('color')->column()
         );
 
+        // ── Каскадные карты для JS-модалки редактирования ─────────────────────
+
+        $subByCatRows = Products1cNomenclature::find()
+            ->select(['category', 'subcategory'])->distinct()
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->andWhere(['not', ['subcategory' => null]])
+            ->orderBy(['category' => SORT_ASC, 'subcategory' => SORT_ASC])
+            ->asArray()->all();
+        $subcategoriesByCategory = [];
+        foreach ($subByCatRows as $row) {
+            $subcategoriesByCategory[$row['category']][] = $row['subcategory'];
+        }
+
+        $specBySubRows = Products1cNomenclature::find()
+            ->select(['subcategory', 'species'])->distinct()
+            ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES])
+            ->andWhere(['not', ['subcategory' => null]])
+            ->andWhere(['not', ['species' => null]])
+            ->orderBy(['subcategory' => SORT_ASC, 'species' => SORT_ASC])
+            ->asArray()->all();
+        $speciesBySubcategory = [];
+        foreach ($specBySubRows as $row) {
+            $speciesBySubcategory[$row['subcategory']][] = $row['species'];
+        }
+
         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,
+            'filter'                  => $filter,
+            'dataProvider'            => $dataProvider,
+            'dataSource'              => $dataSource,
+            'activeStatus'            => $activeStatus,
+            'counters'                => $counters,
+            'categories'              => array_combine($categories, $categories),
+            'subcategoryList'         => array_combine($subcategoryList, $subcategoryList),
+            'speciesList'             => array_combine($speciesList, $speciesList),
+            'sorts'                   => array_values($sorts),
+            'colors'                  => array_values($colors),
+            'pageSize'                => $pageSize,
+            'subcategoriesByCategory' => $subcategoriesByCategory,
+            'speciesBySubcategory'    => $speciesBySubcategory,
         ]);
     }
 
+    // ── Подтверждение одного предсказания автомаркировки ──────────────────────
+
+    public function actionAjaxApprovePrediction(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $predictionId = (int)Yii::$app->request->post('prediction_id');
+        $prediction   = $this->findPrediction($predictionId);
+
+        if (!$prediction->isPending()) {
+            return ['success' => false, 'message' => 'Предсказание уже обработано'];
+        }
+
+        $prediction->status      = Products1cAutomarkPrediction::STATUS_APPROVED;
+        $prediction->approved_by = (int)Yii::$app->user->id;
+        $prediction->updated_at  = date('Y-m-d H:i:s');
+
+        if (!$prediction->save()) {
+            return ['success' => false, 'message' => 'Ошибка сохранения предсказания'];
+        }
+
+        $service = new AutoMarkService();
+        try {
+            if ($service->applyApprovedPrediction($prediction->id)) {
+                AuditLog::write(
+                    Products1cAutomarkPrediction::tableName(),
+                    (string)$prediction->id,
+                    AuditLog::ACTION_UPDATE,
+                    ['status' => Products1cAutomarkPrediction::STATUS_PENDING],
+                    ['status' => Products1cAutomarkPrediction::STATUS_APPROVED, 'approved_by' => $prediction->approved_by],
+                );
+                return ['success' => true, 'message' => 'Разметка подтверждена и применена'];
+            }
+        } catch (\Exception $e) {
+            Yii::error($e->getMessage(), __CLASS__);
+        }
+
+        return ['success' => false, 'message' => 'Ошибка применения разметки в номенклатуру'];
+    }
+
+    // ── Редактирование полей предсказания + подтверждение ─────────────────────
+
+    public function actionAjaxSavePrediction(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $post         = Yii::$app->request->post();
+        $predictionId = (int)($post['prediction_id'] ?? 0);
+        $prediction   = $this->findPrediction($predictionId);
+
+        if (!$prediction->isPending()) {
+            return ['success' => false, 'message' => 'Предсказание уже обработано'];
+        }
+
+        $category = trim($post['category'] ?? '');
+        if (!$category) {
+            return ['success' => false, 'message' => 'Категория обязательна'];
+        }
+
+        $oldValues = array_intersect_key($prediction->getAttributes(), array_flip([
+            'category', 'subcategory', 'species', 'sort', 'color', 'size', 'status',
+        ]));
+
+        $prediction->category    = $category;
+        $prediction->subcategory = trim($post['subcategory'] ?? '') ?: null;
+        $prediction->species     = trim($post['species'] ?? '') ?: null;
+        $prediction->sort        = trim($post['sort'] ?? '') ?: null;
+        $prediction->color       = trim($post['color'] ?? '') ?: null;
+        $prediction->size        = isset($post['size']) && $post['size'] !== '' ? (float)$post['size'] : null;
+        $prediction->status      = Products1cAutomarkPrediction::STATUS_APPROVED;
+        $prediction->approved_by = (int)Yii::$app->user->id;
+        $prediction->updated_at  = date('Y-m-d H:i:s');
+
+        if (!$prediction->save()) {
+            return ['success' => false, 'message' => implode(', ', $prediction->getFirstErrors())];
+        }
+
+        $service = new AutoMarkService();
+        try {
+            if ($service->applyApprovedPrediction($prediction->id)) {
+                AuditLog::write(
+                    Products1cAutomarkPrediction::tableName(),
+                    (string)$prediction->id,
+                    AuditLog::ACTION_UPDATE,
+                    $oldValues,
+                    ['category' => $prediction->category, 'subcategory' => $prediction->subcategory,
+                     'species' => $prediction->species, 'status' => $prediction->status,
+                     'approved_by' => $prediction->approved_by],
+                );
+                return ['success' => true, 'message' => 'Сохранено, подтверждено и применено'];
+            }
+        } catch (\Exception $e) {
+            Yii::error($e->getMessage(), __CLASS__);
+        }
+
+        return ['success' => false, 'message' => 'Ошибка применения разметки в номенклатуру'];
+    }
+
+    // ── Массовое подтверждение предсказаний ───────────────────────────────────
+
+    public function actionAjaxBulkApprovePredictions(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $ids = array_map('intval', array_filter((array)Yii::$app->request->post('prediction_ids', [])));
+        if (empty($ids)) {
+            return ['success' => false, 'message' => 'Не выбраны товары'];
+        }
+
+        $predictions = Products1cAutomarkPrediction::find()
+            ->where(['id' => $ids, 'status' => Products1cAutomarkPrediction::STATUS_PENDING])
+            ->all();
+
+        $service = new AutoMarkService();
+        $now     = date('Y-m-d H:i:s');
+        $userId  = (int)Yii::$app->user->id;
+        $applied = 0;
+
+        foreach ($predictions as $prediction) {
+            $prediction->status      = Products1cAutomarkPrediction::STATUS_APPROVED;
+            $prediction->approved_by = $userId;
+            $prediction->updated_at  = $now;
+
+            if (!$prediction->save()) {
+                continue;
+            }
+
+            try {
+                if ($service->applyApprovedPrediction($prediction->id)) {
+                    $applied++;
+                }
+            } catch (\Exception $e) {
+                Yii::error($e->getMessage(), __CLASS__);
+            }
+        }
+
+        if ($applied > 0) {
+            AuditLog::write(
+                Products1cAutomarkPrediction::tableName(),
+                null,
+                AuditLog::ACTION_BULK_UPDATE,
+                ['status' => Products1cAutomarkPrediction::STATUS_PENDING],
+                ['status' => Products1cAutomarkPrediction::STATUS_APPROVED, 'approved_by' => $userId, 'entity_ids' => $ids],
+            );
+        }
+
+        return [
+            'success' => true,
+            'message' => "Подтверждено и применено: {$applied} из " . count($ids),
+            'applied' => $applied,
+        ];
+    }
+
+    // ── Legacy: подтверждение записи номенклатуры (pending → approved) ─────────
+
     public function actionAjaxApprove(): array
     {
         Yii::$app->response->format = Response::FORMAT_JSON;
@@ -188,9 +401,19 @@ class Products1cNomenclatureMarkupController extends Controller
             ['id' => $guids, 'classification_status' => self::STATUS_PENDING]
         );
 
+        if ($updated > 0) {
+            AuditLog::write(
+                Products1cNomenclature::tableName(),
+                null,
+                AuditLog::ACTION_BULK_UPDATE,
+                ['classification_status' => self::STATUS_PENDING],
+                ['classification_status' => self::STATUS_APPROVED, 'classified_by' => $userId, 'entity_ids' => $guids],
+            );
+        }
+
         return [
             'success' => true,
-            'message' => "Подтверждено: {$updated} из " . count($guids) . ' (только pending)',
+            'message' => "Подтверждено: {$updated} из " . count($guids),
             'updated' => $updated,
         ];
     }
@@ -263,6 +486,7 @@ class Products1cNomenclatureMarkupController extends Controller
             'guid'                  => $p->id,
             'name'                  => $p->name,
             'category'              => $p->category,
+            'subcategory'           => $p->subcategory,
             'species'               => $p->species,
             'sort'                  => $p->sort,
             'color'                 => $p->color,
@@ -289,7 +513,12 @@ class Products1cNomenclatureMarkupController extends Controller
             return ['success' => false, 'message' => 'Категория обязательна'];
         }
 
+        $oldValues = array_intersect_key($product->getAttributes(), array_flip([
+            'category', 'subcategory', 'species', 'sort', 'color', 'size', 'classification_status',
+        ]));
+
         $product->category              = $category;
+        $product->subcategory           = trim($post['subcategory'] ?? '') ?: null;
         $product->species               = trim($post['species'] ?? '') ?: null;
         $product->sort                  = trim($post['sort'] ?? '') ?: null;
         $product->color                 = trim($post['color'] ?? '') ?: null;
@@ -299,16 +528,28 @@ class Products1cNomenclatureMarkupController extends Controller
         $product->classified_at         = date('Y-m-d H:i:s');
         $product->confidence            = null;
 
-        if (!$product->save(false)) {
-            return ['success' => false, 'message' => 'Ошибка сохранения'];
+        if (!$product->save()) {
+            return ['success' => false, 'message' => implode(', ', $product->getFirstErrors())];
         }
 
+        AuditLog::write(
+            Products1cNomenclature::tableName(),
+            $product->id,
+            AuditLog::ACTION_UPDATE,
+            $oldValues,
+            ['category' => $product->category, 'subcategory' => $product->subcategory,
+             'species' => $product->species, 'sort' => $product->sort,
+             'color' => $product->color, 'size' => $product->size,
+             'classification_status' => $product->classification_status, 'classified_by' => $product->classified_by],
+        );
+
         return [
             'success' => true,
             'message' => 'Сохранено и подтверждено',
             'product' => [
                 'guid'                  => $product->id,
                 'category'              => $product->category,
+                'subcategory'           => $product->subcategory,
                 'species'               => $product->species,
                 'sort'                  => $product->sort,
                 'color'                 => $product->color,
@@ -318,16 +559,94 @@ class Products1cNomenclatureMarkupController extends Controller
         ];
     }
 
+    // ── Вспомогательные методы ─────────────────────────────────────────────────
+
+    private function buildDataProvider(DynamicModel $filter, string $activeStatus, int $pageSize): array
+    {
+        if ($activeStatus === self::STATUS_UNCLASSIFIED) {
+            return ['none', null];
+        }
+
+        if ($activeStatus === self::STATUS_NOT_NEEDED) {
+            $query = Products1cNomenclature::find()
+                ->andWhere(['not in', 'category', Products1cNomenclature::EXCLUDED_CATEGORIES]);
+
+            if ($filter->category)    $query->andWhere(['category'    => $filter->category]);
+            if ($filter->subcategory) $query->andWhere(['subcategory' => $filter->subcategory]);
+            if ($filter->species)     $query->andWhere(['species'     => $filter->species]);
+            $query->orderBy(['name' => SORT_ASC]);
+
+            return ['nomenclature', new ActiveDataProvider(['query' => $query, 'pagination' => ['pageSize' => $pageSize]])];
+        }
+
+        if ($activeStatus === self::STATUS_APPROVED) {
+            $query = Products1cAutomarkPrediction::find()
+                ->with('product')
+                ->where(['products_1c_automark_predictions.status' => Products1cAutomarkPrediction::STATUS_APPROVED]);
+
+            if ($filter->category)    $query->andWhere(['products_1c_automark_predictions.category'    => $filter->category]);
+            if ($filter->subcategory) $query->andWhere(['products_1c_automark_predictions.subcategory' => $filter->subcategory]);
+            if ($filter->species)     $query->andWhere(['products_1c_automark_predictions.species'     => $filter->species]);
+            if ($filter->confidence_range) {
+                match ($filter->confidence_range) {
+                    'high'   => $query->andWhere(['>=',      'products_1c_automark_predictions.confidence', 0.9]),
+                    'medium' => $query->andWhere(['between', 'products_1c_automark_predictions.confidence', 0.7, 0.89]),
+                    'low'    => $query->andWhere(['<',       'products_1c_automark_predictions.confidence', 0.7]),
+                    default  => null,
+                };
+            }
+            $query->orderBy([
+                'products_1c_automark_predictions.updated_at' => SORT_DESC,
+                'products_1c_automark_predictions.id'         => SORT_DESC,
+            ]);
+
+            return ['approved_predictions', new ActiveDataProvider(['query' => $query, 'pagination' => ['pageSize' => $pageSize]])];
+        }
+
+        // По умолчанию: предсказания автомаркировки (STATUS_PENDING)
+        $query = Products1cAutomarkPrediction::find()
+            ->with('product')
+            ->where(['products_1c_automark_predictions.status' => Products1cAutomarkPrediction::STATUS_PENDING]);
+
+        if ($filter->category)    $query->andWhere(['products_1c_automark_predictions.category'    => $filter->category]);
+        if ($filter->subcategory) $query->andWhere(['products_1c_automark_predictions.subcategory' => $filter->subcategory]);
+        if ($filter->species)     $query->andWhere(['products_1c_automark_predictions.species'     => $filter->species]);
+        if ($filter->confidence_range) {
+            match ($filter->confidence_range) {
+                'high'   => $query->andWhere(['>=',      'products_1c_automark_predictions.confidence', 0.9]),
+                'medium' => $query->andWhere(['between', 'products_1c_automark_predictions.confidence', 0.7, 0.89]),
+                'low'    => $query->andWhere(['<',       'products_1c_automark_predictions.confidence', 0.7]),
+                default  => null,
+            };
+        }
+        $query->orderBy([
+            'products_1c_automark_predictions.confidence' => SORT_DESC,
+            'products_1c_automark_predictions.id'         => SORT_DESC,
+        ]);
+
+        return ['predictions', new ActiveDataProvider(['query' => $query, 'pagination' => ['pageSize' => $pageSize]])];
+    }
+
     private function applyStatus(Products1cNomenclature $product, string $status): array
     {
+        $oldStatus = $product->classification_status;
+
         $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' => 'Ошибка сохранения'];
+        if (!$product->save()) {
+            return ['success' => false, 'message' => implode(', ', $product->getFirstErrors())];
         }
 
+        AuditLog::write(
+            Products1cNomenclature::tableName(),
+            $product->id,
+            AuditLog::ACTION_UPDATE,
+            ['classification_status' => $oldStatus],
+            ['classification_status' => $status, 'classified_by' => $product->classified_by],
+        );
+
         return ['success' => true, 'status' => $status];
     }
 
@@ -339,4 +658,13 @@ class Products1cNomenclatureMarkupController extends Controller
         }
         return $model;
     }
+
+    private function findPrediction(int $id): Products1cAutomarkPrediction
+    {
+        $model = Products1cAutomarkPrediction::findOne($id);
+        if ($model === null) {
+            throw new NotFoundHttpException("Предсказание #{$id} не найдено");
+        }
+        return $model;
+    }
 }
index add9e22bd459f26129b7ee02dff7cd3ca92ef626..4826414748a416cad9f3533701a6dc5cf986620e 100644 (file)
@@ -12,7 +12,7 @@ use yii_app\records\AdminGroup;
  *   - 1  (DIRECTOR)
  *   - 10 (GROUP_RS_DIRECTOR — Директор розничной сети)
  *   - 81 (GROUP_IT)
- *   - 82 (GROUP_QC_MANAGER — Менеджер контроля качества)
+ *   - 82 (GROUP_KIK_MANAGER — Менеджер контроля качества)
  *
  * После применения ОБЯЗАТЕЛЬНО запустить:
  *   php yii auth/init
@@ -22,9 +22,9 @@ class m260506_120000_add_automark_rbac_permissions extends Migration
     private const PERMISSION = 'automarkReview';
 
     private const TARGET_GROUP_IDS = [
-        AdminGroup::DIRECTOR,        // 1
+        AdminGroup::DIRECTOR,          // 1
         AdminGroup::GROUP_RS_DIRECTOR, // 10
-        AdminGroup::GROUP_IT,        // 81
+        AdminGroup::GROUP_IT,          // 81
         AdminGroup::GROUP_KIK_MANAGER, // 82
     ];
 
index 104245f9062103329ba0ae05852d18042a655624..38ad4e161007eb17e4ee8e83b11a9a07f51850d6 100755 (executable)
@@ -37,9 +37,9 @@ class AdminGroup extends ActiveRecord
     const GROUP_BUSH_DIRECTOR = 7;
     const GROUP_OPERATIONAL_DIRECTOR = 51;
     const GROUP_IT = 81;
-    const GROUP_KIK_MANAGER = 82; // Менеджер контроля качества
+    const GROUP_KIK_MANAGER = 82; // Менеджер контроля качества (задеплоено на prod)
+    const GROUP_CATMANAGER = 82;  // Category Manager (КМ) — алиас GROUP_KIK_MANAGER для ERP-325
     const GROUP_FINANCE_DIRECTOR = 9;
-    const GROUP_CATMANAGER = 82; // ERP-337: Category Manager (КМ) — полный доступ к каталогу
 
     /**
      * Возвращает список специальных групп работников (нужно для JS и логики грейдов)
index 8b787dd803a1afe3889f8fe2c65f8ae218d148e5..bd5c369e3e4b7802131a75d2b6cd0302b09f5ff9 100644 (file)
@@ -27,6 +27,13 @@ use yii\db\ActiveQuery;
  */
 class Products1cNomenclature extends \yii\db\ActiveRecord
 {
+    public const CLASSIFICATION_NOT_NEEDED   = 'not_needed';
+    public const CLASSIFICATION_UNCLASSIFIED = 'unclassified';
+    public const CLASSIFICATION_PENDING      = 'pending';
+    public const CLASSIFICATION_APPROVED     = 'approved';
+
+    public const EXCLUDED_CATEGORIES = ['букет', 'сборка', 'сервис'];
+
     public $date_from;
     public $date_to;
     /**
index 1f72cf3b7c629206cb5535a71e9348031d9b2b22..cf2d441756034db0135ea7b09793b3078caf6371 100644 (file)
@@ -18,7 +18,7 @@ class AutoMarkService
     public const RULE_THRESHOLD       = 0.9;
     public const SIMILARITY_THRESHOLD = 0.7;
 
-    private const EXCLUDED_TYPES = [
+    public const EXCLUDED_TYPES = [
         '[букет]',
         '[сборка]',
         'Матрица',
@@ -98,13 +98,17 @@ class AutoMarkService
                 $nomenclature->type_num = '';
             }
 
-            $nomenclature->category    = $prediction->category    ?? $nomenclature->category;
-            $nomenclature->subcategory = $prediction->subcategory ?? $nomenclature->subcategory;
-            $nomenclature->species     = $prediction->species     ?? $nomenclature->species;
-            $nomenclature->sort        = $prediction->sort        ?? $nomenclature->sort;
-            $nomenclature->type        = $prediction->type        ?? $nomenclature->type;
-            $nomenclature->size        = $prediction->size        ?? $nomenclature->size;
-            $nomenclature->color       = $prediction->color       ?? $nomenclature->color;
+            $nomenclature->category              = $prediction->category    ?? $nomenclature->category;
+            $nomenclature->subcategory           = $prediction->subcategory ?? $nomenclature->subcategory;
+            $nomenclature->species               = $prediction->species     ?? $nomenclature->species;
+            $nomenclature->sort                  = $prediction->sort        ?? $nomenclature->sort;
+            $nomenclature->type                  = $prediction->type        ?? $nomenclature->type;
+            $nomenclature->size                  = $prediction->size        ?? $nomenclature->size;
+            $nomenclature->color                 = $prediction->color       ?? $nomenclature->color;
+            $nomenclature->classification_status = Products1cNomenclature::CLASSIFICATION_APPROVED;
+            $nomenclature->confidence            = (int) round($prediction->confidence * 100);
+            $nomenclature->classified_by         = $prediction->approved_by;
+            $nomenclature->classified_at         = date('Y-m-d H:i:s');
 
             // save(false): location/type_num могут быть '', required-валидатор для форм здесь неприменим
             if (!$nomenclature->save(false)) {
index 12358774d86124798df6841725e6d46d89790384..adcbea01337b3dbbeb5c714b86253a315e9856ec 100644 (file)
@@ -25,7 +25,7 @@ trait AuditableTrait
      * Логирует создание/обновление.
      * Для update пишет только реально изменённые атрибуты — не срабатывает на no-op save.
      */
-    public function afterSave(bool $insert, array $changedAttributes): void
+    public function afterSave($insert, $changedAttributes): void
     {
         parent::afterSave($insert, $changedAttributes);
 
index 0a07475e1b4520e7e011bf494c5b0110d334c3b9..8a8f28ef73061c57336896c1bcd44cedfee5e745 100644 (file)
@@ -10,7 +10,7 @@ use yii\web\View;
 
 $this->title = 'Ассортиментная матрица';
 $this->params['breadcrumbs'][] = $this->title;
-$this->registerJsFile('/js/assortmentLabel/index.js', ['position' => View::POS_END]);
+$this->registerJsFile('/js/assortmentLabel/index.js?v=' . filemtime(Yii::getAlias('@webroot/js/assortmentLabel/index.js')), ['position' => View::POS_END]);
 $this->registerCss(<<<'CSS'
 :root { --ao: #1e3a5f; --cat: #6f42c1; }
 .cm-tabs { border-bottom: 2px solid #dee2e6; margin-bottom: 20px; }
index c0c4929736eb7fdc60b7c921ace5bf2faa97de58..de6a81746cc991faf0a02b7f73bdc422bc12e823 100644 (file)
@@ -12,6 +12,17 @@ use yii\widgets\LinkPager;
 /* @var array $categories */
 /* @var array $subcategories */
 /* @var array $species */
+/* @var array $subcategoriesByCategory */
+/* @var array $speciesBySubcategory */
+/* @var array $sortsBySpecies */
+/* @var array $sortsBySubcategory */
+/* @var array $sortsByCategory */
+/* @var array $colorsBySpecies */
+/* @var array $colorsBySubcategory */
+/* @var array $colorsByCategory */
+/* @var array $sizesBySpecies */
+/* @var array $sizesBySubcategory */
+/* @var array $sizesByCategory */
 /* @var array $types */
 /* @var array $colors */
 /* @var array $sorts */
@@ -24,100 +35,7 @@ use yii\widgets\LinkPager;
 $this->title = 'Актуальность ассортимента';
 $this->params['breadcrumbs'][] = $this->title;
 $this->registerJsFile('/js/products1cNomenclatureActuality/index.js', ['position' => View::POS_END]);
-$this->registerCss(<<<'CSS'
-:root { --ao: #1e3a5f; --cat: #6f42c1; }
-
-/* ── Tab nav ── */
-.cm-tabs { border-bottom: 2px solid #dee2e6; margin-bottom: 20px; }
-.cm-tabs .nav-link { font-size: 14px; color: #495057; border: none; padding: 10px 20px; font-weight: 500;
-    border-bottom: 3px solid transparent; margin-bottom: -2px; background: none; }
-.cm-tabs .nav-link.active { color: var(--cat); font-weight: 700; border-bottom-color: var(--cat); }
-.cm-tabs .nav-link:hover:not(.active) { color: var(--ao); }
-.cm-tabs .tc { font-size: 10px; font-weight: 700; margin-left: 6px; padding: 1px 6px; border-radius: 10px;
-    background: #e9ecef; color: #495057; display: inline-block; }
-.cm-tabs .nav-link.active .tc { background: var(--cat); color: #fff; }
-.cm-tabs .tc-danger { background: #dc3545 !important; color: #fff !important; }
-
-/* ── Stat cards ── */
-.sc { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
-.sc-card { background: #fff; border-radius: 8px; padding: 14px 18px; flex: 1; min-width: 130px;
-    border: 1px solid #dee2e6; }
-.sc-card .lb { font-size: 11px; color: #6c757d; text-transform: uppercase; letter-spacing: .5px; }
-.sc-card .vl { font-size: 24px; font-weight: 700; }
-.sc-card .su { font-size: 11px; color: #6c757d; }
-
-/* ── Filter bar ── */
-.fb { background: #fff; border: 1px solid #dee2e6; border-radius: 8px;
-    padding: 12px 16px; margin-bottom: 12px; display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
-.fb .fg { display: flex; flex-direction: column; gap: 2px; }
-.fb .fg > label { font-size: 11px; color: #6c757d; font-weight: 500; margin: 0; }
-.fb .form-select, .fb .form-control { font-size: 12px; padding: 4px 8px; height: 30px; }
-.fb-sep { width: 1px; height: 30px; background: #dee2e6; align-self: flex-end; flex-shrink: 0; }
-
-/* ── Bulk bar ── */
-.bulk-bar { background: var(--ao); color: #fff; border-radius: 8px; padding: 10px 16px;
-    margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
-.bulk-bar .btn { font-size: 11px; }
-
-/* ── Product card ── */
-.pr { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; margin-bottom: 10px;
-    padding: 14px 16px; transition: box-shadow .15s; }
-.pr:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
-.pr-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 10px; }
-.pr-left { display: flex; align-items: flex-start; gap: 10px; flex-shrink: 0; max-width: 300px; }
-.pr-name { font-size: 15px; font-weight: 600; color: var(--ao); margin: 2px 0; line-height: 1.3; }
-.pr-sub { font-size: 11px; color: #6c757d; word-break: break-all; }
-.pr-center { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
-.pr-intervals { display: flex; gap: 8px; flex-wrap: wrap; }
-.pr-int { font-size: 11px; padding: 3px 10px; border-radius: 4px; border: 1px solid #dee2e6;
-    color: #495057; background: #f8f9fa; white-space: nowrap; }
-.pr-int.active { border-color: #198754; color: #198754; background: #e8f5e9; font-weight: 600; }
-.pr-int.future { border-color: #0d6efd; color: #0d6efd; background: #e7f1ff; }
-.pr-int.past   { border-color: #dee2e6; color: #adb5bd; background: #f8f9fa; text-decoration: line-through; }
-.pr-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
-.score-inline { display: flex; align-items: center; gap: 5px; font-size: 12px; white-space: nowrap; }
-.sc-stars { display: inline-flex; gap: 1px; font-size: 12px; }
-.sc-stars i { color: #dee2e6; }
-.sc-stars i.on { color: #ffc107; }
-
-/* ── Label chips ── */
-.ch { display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600;
-    margin: 1px 2px; border: 1px solid transparent; position: relative; }
-.ch-off  { background: #e8f5e9; color: #2e7d32; border-color: #c8e6c9; }
-.ch-site { background: #e3f2fd; color: #1565c0; border-color: #bbdefb; }
-.ch-mp   { background: #fce4ec; color: #c62828; border-color: #f8bbd0; }
-.ch-1p   { background: #fff8e1; color: #f57f17; border-color: #ffecb3; }
-.ch.ed { cursor: default; }
-.ch-rm { display: none; position: absolute; top: -4px; right: -4px; width: 13px; height: 13px;
-    border-radius: 50%; background: #dc3545; color: #fff; border: none; font-size: 8px;
-    line-height: 13px; text-align: center; cursor: pointer; padding: 0; }
-.ch.ed:hover .ch-rm { display: block; }
-
-/* ── Concept chips ── */
-.concept { display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 500;
-    margin: 1px 2px; background: #f3e8ff; color: #7c3aed; border: 1px dashed #d8b4fe; }
-
-/* ── Bottom of card ── */
-.lbl-title { font-size: 10px; color: #6c757d; text-transform: uppercase; letter-spacing: .3px;
-    display: block; margin-bottom: 3px; }
-.btn-pr-edit { font-size: 11px; padding: 4px 14px; color: #6c757d; border: 1px solid #dee2e6;
-    background: #fff; border-radius: 4px; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
-.btn-pr-edit:hover { color: #495057; border-color: #adb5bd; background: #f8f9fa; }
-
-/* ── Score collapse block ── */
-.scb { margin-top: 8px; border-top: 1px solid #f0f0f0; }
-.scb-toggle { font-size: 11px; color: var(--cat); cursor: pointer; padding: 5px 0;
-    display: flex; align-items: center; gap: 6px; user-select: none; }
-.scb-toggle:hover { color: var(--ao); }
-.scb-toggle .scb-chevron { font-size: 9px; transition: transform .15s; }
-.scb-toggle.open .scb-chevron { transform: rotate(90deg); }
-.scb-content { display: none; padding: 4px 0; }
-.scb-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0;
-    font-size: 11px; border-bottom: 1px solid #f5f5f5; }
-.scb-row:last-child { border-bottom: none; }
-.scb-name { color: var(--ao); font-weight: 600; font-size: 11px; }
-.scb-comment { font-size: 10px; color: #6c757d; font-style: italic; padding: 1px 0 3px; }
-CSS);
+$this->registerCssFile('/css/products1cNomenclatureActuality/index.css');
 
 function monthList(): array
 {
@@ -339,16 +257,13 @@ function actConceptChips(array $names): string
                 ->label(false) ?>
         </div>
 
-        <div class="fg align-self-end">
-            <label>&nbsp;</label>
-            <div class="d-flex gap-1 align-items-center" style="height:30px">
-                <?= $formFilter->field($filter, 'onlyActive', ['options' => ['class' => 'mb-0 me-1']])
-                    ->checkbox(['label' => 'Только активные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyActive, 'id' => 'onlyActiveCheckbox'])
-                    ->label(false) ?>
-                <?= $formFilter->field($filter, 'onlyInactive', ['options' => ['class' => 'mb-0']])
-                    ->checkbox(['label' => 'Только неактивные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyInactive, 'id' => 'onlyInactiveCheckbox'])
-                    ->label(false) ?>
-            </div>
+        <div class="d-flex align-self-end" style="flex-direction:column;gap:3px">
+            <?= $formFilter->field($filter, 'onlyActive', ['options' => ['class' => 'mb-0']])
+                ->checkbox(['label' => 'Только активные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyActive, 'id' => 'onlyActiveCheckbox'])
+                ->label(false) ?>
+            <?= $formFilter->field($filter, 'onlyInactive', ['options' => ['class' => 'mb-0']])
+                ->checkbox(['label' => 'Только неактивные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyInactive, 'id' => 'onlyInactiveCheckbox'])
+                ->label(false) ?>
         </div>
 
         <div class="fb-sep"></div>
@@ -436,7 +351,7 @@ function actConceptChips(array $names): string
                        style="width:16px;height:16px;flex-shrink:0;margin-top:4px"
                        id="rc-<?= Html::encode($p->id) ?>">
                 <div style="min-width:0">
-                    <span style="background:<?= $bBg ?>;color:<?= $bColor ?>;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600"><?= $bText ?></span>
+                    <span class="pr-status-badge" style="background:<?= $bBg ?>;color:<?= $bColor ?>;font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600"><?= $bText ?></span>
                     <h5 class="pr-name"><?= Html::encode($p->name) ?></h5>
                     <div class="pr-sub">
                         GUID:&nbsp;<?= Html::encode(substr($p->id, 0, 8)) ?>…
@@ -449,7 +364,7 @@ function actConceptChips(array $names): string
 
             <!-- Center: intervals -->
             <div class="pr-center">
-                <span style="font-size:10px;color:#6c757d;text-transform:uppercase">
+                <span class="pr-intervals-title" style="font-size:10px;color:#6c757d;text-transform:uppercase">
                     <?= $status === 'none' ? '<span class="text-danger">Нет интервалов</span>' : 'Предстоящие интервалы' ?>
                 </span>
                 <div class="pr-intervals">
@@ -636,7 +551,21 @@ function actConceptChips(array $names): string
 
 <script>
 window.productActualityConfig = {
-    months: <?= json_encode($months, JSON_UNESCAPED_UNICODE) ?>,
+    months:                  <?= json_encode($months, JSON_UNESCAPED_UNICODE) ?>,
+    subcategoriesByCategory: <?= json_encode($subcategoriesByCategory, JSON_UNESCAPED_UNICODE) ?>,
+    speciesBySubcategory:    <?= json_encode($speciesBySubcategory, JSON_UNESCAPED_UNICODE) ?>,
+    sortsBySpecies:          <?= json_encode($sortsBySpecies, JSON_UNESCAPED_UNICODE) ?>,
+    sortsBySubcategory:      <?= json_encode($sortsBySubcategory, JSON_UNESCAPED_UNICODE) ?>,
+    sortsByCategory:         <?= json_encode($sortsByCategory, JSON_UNESCAPED_UNICODE) ?>,
+    colorsBySpecies:         <?= json_encode($colorsBySpecies, JSON_UNESCAPED_UNICODE) ?>,
+    colorsBySubcategory:     <?= json_encode($colorsBySubcategory, JSON_UNESCAPED_UNICODE) ?>,
+    colorsByCategory:        <?= json_encode($colorsByCategory, JSON_UNESCAPED_UNICODE) ?>,
+    sizesBySpecies:          <?= json_encode($sizesBySpecies, JSON_UNESCAPED_UNICODE) ?>,
+    sizesBySubcategory:      <?= json_encode($sizesBySubcategory, JSON_UNESCAPED_UNICODE) ?>,
+    sizesByCategory:         <?= json_encode($sizesByCategory, JSON_UNESCAPED_UNICODE) ?>,
+    allSorts:                <?= json_encode(array_values($sorts), JSON_UNESCAPED_UNICODE) ?>,
+    allColors:               <?= json_encode(array_values($colors), JSON_UNESCAPED_UNICODE) ?>,
+    allSizes:                <?= json_encode(array_values($sizes), JSON_UNESCAPED_UNICODE) ?>,
     urls: {
         intervals:      '<?= Url::to(['/products1c-nomenclature-actuality/ajax-intervals']) ?>',
         saveInterval:   '<?= Url::to(['/products1c-nomenclature-actuality/ajax-save-interval']) ?>',
index d9bcf7c5027c1360cac5044a765dd574f9037988..3b537f8e9f3274834bdbc06d75d7c6f3aa8ab5c2 100644 (file)
@@ -9,17 +9,22 @@ use yii\web\View;
 
 /* @var $this yii\web\View */
 /* @var $filter yii\base\DynamicModel */
-/* @var $dataProvider yii\data\ActiveDataProvider */
+/* @var $dataProvider yii\data\ActiveDataProvider|null */
+/* @var $dataSource string  'predictions'|'nomenclature'|'approved_predictions'|'none' */
+/* @var $activeStatus string */
 /* @var $counters array */
 /* @var $categories array */
+/* @var $subcategoryList array */
 /* @var $speciesList array */
+/* @var array $subcategoriesByCategory */
+/* @var array $speciesBySubcategory */
 /* @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]);
+$this->registerJsFile('/js/products1cNomenclatureMarkup/index.js?v=' . filemtime(Yii::getAlias('@webroot/js/products1cNomenclatureMarkup/index.js')), ['position' => View::POS_END]);
 $this->registerCss(<<<'CSS'
 :root { --ao: #1e3a5f; --cat: #6f42c1; }
 .cm-tabs { border-bottom: 2px solid #dee2e6; margin-bottom: 20px; }
@@ -35,10 +40,9 @@ $this->registerCss(<<<'CSS'
     padding: 12px 16px; margin-bottom: 12px; display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
 .fb .fg { display: flex; flex-direction: column; gap: 2px; }
 .fb .fg > label { font-size: 11px; color: #6c757d; font-weight: 500; margin: 0; }
-.fb .form-select { font-size: 12px; padding: 4px 8px; height: 30px; }
+.fb .form-select { font-size: 12px; padding: 4px 2rem 4px 8px; height: 30px; min-width: 130px; }
+.modal.fade.show { opacity: 1; }
 CSS);
-
-$statusLabels = MarkupCtrl::statusLabels();
 ?>
 
 <div class="products1c-nomenclature-markup-index p-4">
@@ -55,7 +59,7 @@ $statusLabels = MarkupCtrl::statusLabels();
         <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="tc tc-danger"><?= $counters[MarkupCtrl::STATUS_PENDING] ?></span>
+                <span class="tc tc-danger" id="markupTabBadge"><?= $counters[MarkupCtrl::STATUS_AUTOMARK_PENDING] ?></span>
             </a>
         </li>
         <li class="nav-item">
@@ -65,41 +69,68 @@ $statusLabels = MarkupCtrl::statusLabels();
         </li>
     </ul>
 
-    <!-- Counter cards -->
+    <!-- Counter cards — 4 источника -->
     <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>
+            <a href="<?= Url::to(['index', 'DynamicModel[classification_status]' => MarkupCtrl::STATUS_NOT_NEEDED]) ?>"
+               class="text-decoration-none">
+                <div class="card border-0 px-3 py-2 text-center <?= $activeStatus === MarkupCtrl::STATUS_NOT_NEEDED ? 'border-secondary' : '' ?>"
+                     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;">Все товары в 1С номенклатуре</div>
+                </div>
+            </a>
         </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>
+            <a href="<?= Url::to(['index', 'DynamicModel[classification_status]' => MarkupCtrl::STATUS_AUTOMARK_PENDING]) ?>"
+               class="text-decoration-none">
+                <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" id="markupCntPending" style="color:#b86d00;"><?= $counters[MarkupCtrl::STATUS_AUTOMARK_PENDING] ?></div>
+                    <div class="small text-muted">Ожидают подтверждения</div>
+                    <div style="font-size:10px;color:#adb5bd;">Ожидает проверки КМ</div>
+                </div>
+            </a>
         </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>
+            <a href="<?= Url::to(['index', 'DynamicModel[classification_status]' => MarkupCtrl::STATUS_APPROVED]) ?>"
+               class="text-decoration-none">
+                <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" id="markupCntApproved"><?= $counters[MarkupCtrl::STATUS_APPROVED] ?></div>
+                    <div class="small text-muted">Подтверждены</div>
+                    <div style="font-size:10px;color:#adb5bd;">Одобрено через авторазметку</div>
+                </div>
+            </a>
         </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>
+            <a href="<?= Url::to(['index', 'DynamicModel[classification_status]' => MarkupCtrl::STATUS_UNCLASSIFIED]) ?>"
+               class="text-decoration-none">
+                <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>
+            </a>
         </div>
     </div>
 
     <!-- Фильтры -->
     <?php $form = ActiveForm::begin(['method' => 'get', 'action' => ['index'], 'options' => ['class' => 'mb-0']]); ?>
     <div class="fb">
+        <div class="fg">
+            <label>Статус разметки</label>
+            <?= $form->field($filter, 'classification_status', ['options' => ['class' => 'mb-0']])
+                ->dropDownList([
+                    MarkupCtrl::STATUS_AUTOMARK_PENDING => 'Ожидают подтверждения',
+                    MarkupCtrl::STATUS_NOT_NEEDED       => 'Не нужна разметка',
+                    MarkupCtrl::STATUS_APPROVED         => 'Подтверждены',
+                    MarkupCtrl::STATUS_UNCLASSIFIED     => 'Не размечены (счётчик)',
+                ], ['class' => 'form-select'])
+                ->label(false) ?>
+        </div>
         <div class="fg">
             <label>Категория</label>
             <?= $form->field($filter, 'category', ['options' => ['class' => 'mb-0']])
@@ -107,21 +138,15 @@ $statusLabels = MarkupCtrl::statusLabels();
                 ->label(false) ?>
         </div>
         <div class="fg">
-            <label>Ð\92ид</label>
-            <?= $form->field($filter, 'species', ['options' => ['class' => 'mb-0']])
-                ->dropDownList($speciesList, ['prompt' => 'Все', 'class' => 'form-select'])
+            <label>Ð\9fодкаÑ\82егоÑ\80иÑ\8f</label>
+            <?= $form->field($filter, 'subcategory', ['options' => ['class' => 'mb-0']])
+                ->dropDownList($subcategoryList, ['prompt' => 'Все', 'class' => 'form-select'])
                 ->label(false) ?>
         </div>
         <div class="fg">
-            <label>Статус разметки</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'])
+            <label>Вид</label>
+            <?= $form->field($filter, 'species', ['options' => ['class' => 'mb-0']])
+                ->dropDownList($speciesList, ['prompt' => 'Все', 'class' => 'form-select'])
                 ->label(false) ?>
         </div>
         <div class="fg">
@@ -151,126 +176,266 @@ $statusLabels = MarkupCtrl::statusLabels();
          style="background:var(--ao);color:#fff">
         <span class="fw-semibold" id="markupBulkCount">0 выбрано</span>
         <button class="btn btn-sm btn-outline-light" id="markupBulkApproveBtn">
-            <i class="fa fa-check-double me-1"></i>Подтвердить выбранные
+            <i class="fa fa-check-double me-1"></i>
+            <?= $dataSource === 'predictions' ? 'Подтвердить и применить выбранные' : 'Подтвердить выбранные' ?>
         </button>
         <button class="btn btn-sm btn-outline-warning 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 . ' см' : '—',
+    <?php if ($dataSource === 'none'): ?>
+
+        <!-- Информационный блок: товары без авторазметки -->
+        <div class="alert d-flex align-items-start gap-3 mt-3" style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:8px;">
+            <i class="fa fa-info-circle fa-2x text-secondary mt-1"></i>
+            <div>
+                <strong><?= number_format($counters[MarkupCtrl::STATUS_UNCLASSIFIED]) ?></strong> товаров из <code>products_1c</code>
+                не имеют ни предсказания авторазметки, ни записи в номенклатуре.
+                <br>
+                <span class="text-muted small">
+                    Запустите авторазметку для создания предсказаний:
+                    <code>php yii auto-mark/batch-predict</code>
+                </span>
+            </div>
+        </div>
+
+    <?php elseif ($dataSource === 'predictions'): ?>
+
+        <!-- Грид: предсказания автомаркировки (STATUS_PENDING) -->
+        <?= 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;'],
+            'emptyText'        => '<div class="text-center text-success py-4"><i class="fa fa-check-circle fa-2x mb-2"></i><br>Нет предсказаний, ожидающих подтверждения</div>',
+            'rowOptions'       => function ($m) {
+                $conf  = (int) round($m->confidence * 100);
+                $style = match (true) {
+                    $conf >= 90 => '',
+                    $conf >= 70 => 'background:#fffbeb',
+                    default     => 'background:#fff5f5',
+                };
+                return ['data-prediction-id' => $m->id, '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="' . $m->id . '">',
+                ],
+                [
+                    'label'          => 'Товар',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'min-width:180px'],
+                    'value'          => fn($m) => '<strong>' . Html::encode($m->product?->name ?? '—') . '</strong><br>'
+                        . '<span class="text-muted" style="font-size:10px">' . Html::encode($m->product?->code ?? '') . '</span>',
+                ],
+                [
+                    'label' => 'Категория',
+                    'value' => fn($m) => $m->category ?? '—',
+                ],
+                [
+                    'label' => 'Подкатегория',
+                    'value' => fn($m) => $m->subcategory ?? '—',
+                ],
+                [
+                    'label' => 'Вид',
+                    'value' => fn($m) => $m->species ?? '—',
+                ],
+                [
+                    'label' => 'Сорт',
+                    'value' => fn($m) => $m->sort ?? '—',
+                ],
+                [
+                    'label' => 'Цвет',
+                    'value' => fn($m) => $m->color ?? '—',
+                ],
+                [
+                    'label'          => 'Высота',
+                    'contentOptions' => ['style' => 'text-align:center'],
+                    'value'          => fn($m) => $m->size ? $m->size . ' см' : '—',
+                ],
+                [
+                    'label'          => 'Уверенность',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap'],
+                    'value'          => function ($m) {
+                        $conf = (int) round($m->confidence * 100);
+                        $cls  = match (true) {
+                            $conf >= 90 => 'success',
+                            $conf >= 70 => 'warning text-dark',
+                            default     => 'danger',
+                        };
+                        return '<span class="badge bg-' . $cls . '">' . $conf . '%</span>';
+                    },
+                ],
+                [
+                    'label'          => 'Метод',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap'],
+                    'value'          => fn($m) => match ($m->method) {
+                        'rule'       => '<span class="badge bg-info text-dark">правило</span>',
+                        'similarity' => '<span class="badge bg-secondary">схожесть</span>',
+                        'llm'        => '<span class="badge bg-primary">LLM</span>',
+                        default      => Html::encode($m->method),
+                    },
+                ],
+                [
+                    'label'          => 'LLM',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap'],
+                    'value'          => fn($m) => match ($m->llm_verdict) {
+                        'approved' => '<span class="badge bg-success"><i class="fa fa-check me-1"></i>одобр.</span>',
+                        'rejected' => '<span class="badge bg-danger"><i class="fa fa-times me-1"></i>откл.</span>',
+                        default    => '—',
+                    },
+                ],
+                [
+                    'label'          => 'Действие',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap;width:80px'],
+                    'value'          => function ($m) {
+                        $predId = $m->id;
+                        $name   = Html::encode($m->product?->name ?? '');
+                        $cat    = Html::encode((string)$m->category);
+                        $subcat = Html::encode((string)$m->subcategory);
+                        $sp     = Html::encode((string)$m->species);
+                        $sort   = Html::encode((string)$m->sort);
+                        $color  = Html::encode((string)$m->color);
+                        $size   = (string)($m->size ?? '');
+
+                        $approveBtn = '<button class="btn btn-xs btn-success markup-approve-prediction-btn me-1"'
+                            . ' title="Подтвердить" data-prediction-id="' . $predId . '">'
+                            . '<i class="fa fa-check"></i></button>';
+
+                        $editBtn = '<button class="btn btn-xs btn-outline-secondary markup-edit-prediction-btn"'
+                            . ' title="Редактировать и подтвердить"'
+                            . ' data-prediction-id="' . $predId . '" data-name="' . $name . '"'
+                            . ' data-category="' . $cat . '" data-subcategory="' . $subcat . '" data-species="' . $sp . '"'
+                            . ' data-sort="' . $sort . '" data-color="' . $color . '" data-size="' . $size . '">'
+                            . '<i class="fa fa-pencil"></i></button>';
+
+                        return $approveBtn . $editBtn;
+                    },
+                ],
             ],
-            [
-                '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>';
-                },
+        ]); ?>
+
+    <?php elseif ($dataSource === 'approved_predictions'): ?>
+
+        <!-- Грид: одобрённые предсказания (только просмотр) -->
+        <?= 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;'],
+            'emptyText'        => '<div class="text-center text-muted py-4">Нет одобрённых предсказаний</div>',
+            'rowOptions'       => ['style' => 'background:#f0fff4'],
+            'columns' => [
+                [
+                    'label'          => 'Товар',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'min-width:180px'],
+                    'value'          => fn($m) => '<strong>' . Html::encode($m->product?->name ?? '—') . '</strong><br>'
+                        . '<span class="text-muted" style="font-size:10px">' . Html::encode($m->product?->code ?? '') . '</span>',
+                ],
+                ['label' => 'Категория',    'value' => fn($m) => $m->category    ?? '—'],
+                ['label' => 'Подкатегория', 'value' => fn($m) => $m->subcategory ?? '—'],
+                ['label' => 'Вид',          'value' => fn($m) => $m->species     ?? '—'],
+                ['label' => 'Сорт',         'value' => fn($m) => $m->sort        ?? '—'],
+                ['label' => 'Цвет',         'value' => fn($m) => $m->color       ?? '—'],
+                [
+                    'label'          => 'Высота',
+                    'contentOptions' => ['style' => 'text-align:center'],
+                    'value'          => fn($m) => $m->size ? $m->size . ' см' : '—',
+                ],
+                [
+                    'label'          => 'Уверенность',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap'],
+                    'value'          => function ($m) {
+                        $conf = (int) round($m->confidence * 100);
+                        $cls  = match (true) {
+                            $conf >= 90 => 'success',
+                            $conf >= 70 => 'warning text-dark',
+                            default     => 'danger',
+                        };
+                        return '<span class="badge bg-' . $cls . '">' . $conf . '%</span>';
+                    },
+                ],
+                [
+                    'label'          => 'Метод',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap'],
+                    'value'          => fn($m) => match ($m->method) {
+                        'rule'       => '<span class="badge bg-info text-dark">правило</span>',
+                        'similarity' => '<span class="badge bg-secondary">схожесть</span>',
+                        'llm'        => '<span class="badge bg-primary">LLM</span>',
+                        default      => Html::encode($m->method ?? '—'),
+                    },
+                ],
+                [
+                    'label'          => 'Одобрен',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap;font-size:11px'],
+                    'value'          => fn($m) => $m->updated_at ? date('d.m.Y', strtotime($m->updated_at)) : '—',
+                ],
             ],
-            [
-                '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;
-                },
+        ]); ?>
+
+    <?php else: ?>
+
+        <!-- Грид: записи номенклатуры «Не нужна разметка» (только просмотр) -->
+        <?= 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'       => ['style' => 'background:#f8f9fa;opacity:.85'],
+            'columns' => [
+                [
+                    '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' => 'Категория'],
+                ['label' => 'Подкатегория', 'value' => fn($m) => $m->subcategory ?? '—'],
+                ['label' => 'Вид',          'value' => fn($m) => $m->species     ?? '—'],
+                ['label' => 'Сорт',         'value' => fn($m) => $m->sort        ?? '—'],
+                ['label' => 'Цвет',         'value' => fn($m) => $m->color       ?? '—'],
+                [
+                    'label'          => 'Высота',
+                    'contentOptions' => ['style' => 'text-align:center'],
+                    'value'          => fn($m) => $m->size ? $m->size . ' см' : '—',
+                ],
+                [
+                    'label'          => 'Уверенность',
+                    'format'         => 'raw',
+                    'contentOptions' => ['style' => 'text-align:center;white-space:nowrap'],
+                    'value'          => function ($m) {
+                        if ($m->confidence === null) return '—';
+                        $cls = match (true) {
+                            $m->confidence >= 90 => 'success',
+                            $m->confidence >= 70 => 'warning text-dark',
+                            default              => 'danger',
+                        };
+                        return '<span class="badge bg-' . $cls . '">' . $m->confidence . '%</span>';
+                    },
+                ],
             ],
-        ],
-    ]); ?>
+        ]); ?>
+
+    <?php endif; ?>
 
 </div>
 
@@ -298,7 +463,7 @@ $statusLabels = MarkupCtrl::statusLabels();
     </div>
 </div>
 
-<!-- Модалка: Редактировать разметку -->
+<!-- Модалка: Редактировать разметку (используется для обоих источников) -->
 <div class="modal fade" id="markupEditModal" tabindex="-1" aria-labelledby="markupEditModalLabel" aria-hidden="true">
     <div class="modal-dialog">
         <div class="modal-content">
@@ -308,6 +473,7 @@ $statusLabels = MarkupCtrl::statusLabels();
             </div>
             <div class="modal-body">
                 <input type="hidden" id="markupEditGuid">
+                <input type="hidden" id="markupEditPredictionId">
                 <div class="p-2 mb-3 rounded" style="background:#f0f4f8">
                     <strong id="markupEditName" class="text-primary"></strong>
                 </div>
@@ -318,6 +484,12 @@ $statusLabels = MarkupCtrl::statusLabels();
                         <option value="">— выберите —</option>
                     </select>
                 </div>
+                <div class="mb-3">
+                    <label class="form-label fw-semibold" style="font-size:11px">Подкатегория</label>
+                    <select id="markupEditSubcategory" 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">
@@ -358,16 +530,23 @@ $statusLabels = MarkupCtrl::statusLabels();
 
 <script>
 window.markupConfig = {
+    dataSource: '<?= $dataSource ?>',
     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']) ?>',
+        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']) ?>',
+        approvePrediction:      '<?= Url::to(['/products1c-nomenclature-markup/ajax-approve-prediction']) ?>',
+        savePrediction:         '<?= Url::to(['/products1c-nomenclature-markup/ajax-save-prediction']) ?>',
+        bulkApprovePredictions: '<?= Url::to(['/products1c-nomenclature-markup/ajax-bulk-approve-predictions']) ?>',
     },
-    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) ?>,
+    categories:              <?= json_encode(array_values($categories), JSON_UNESCAPED_UNICODE) ?>,
+    subcategoryList:         <?= json_encode(array_values($subcategoryList), 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) ?>,
+    subcategoriesByCategory: <?= json_encode($subcategoriesByCategory, JSON_UNESCAPED_UNICODE) ?>,
+    speciesBySubcategory:    <?= json_encode($speciesBySubcategory, JSON_UNESCAPED_UNICODE) ?>,
 };
 </script>
index 596af7ef0f25073491d08a63fac06f0c5c5cb9f2..1daab10bea97f41fcc4690fba06efd822bfeb1a1 100755 (executable)
     overflow:visible;
     vertical-align:-.125em;
     width:.875em
-}
\ No newline at end of file
+}
+
+/* Bootstrap 3/5 conflict: Bootstrap 3 CSS loads last in the minified bundle
+   and sets .fade{opacity:0} without .fade.show{opacity:1} (Bootstrap 3 used .fade.in).
+   Bootstrap 5 uses .show class — so ALL .fade.show elements (modals, tab-panes) become invisible.
+   Rule order matters: same specificity (0,2,0) — last wins within this file. */
+.fade.show { opacity: 1; }
+.modal-backdrop.show { opacity: 0.5; }
+.modal.show .modal-dialog { transform: none; }
\ No newline at end of file
index 59068ebba6082a571461dd4118e04558d92f3466..7195e7d3363f757def886b8554099360e80b254b 100644 (file)
@@ -1,5 +1,11 @@
 document.addEventListener('DOMContentLoaded', () => {
 
+    const esc = s => s == null ? '' : String(s)
+        .replace(/&/g, '&amp;').replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+
+    const safeColor = c => /^#[0-9A-Fa-f]{6}$/.test(c) ? c : '#6c757d';
+
     const cfg  = window.assortmentLabelConfig || {};
     const urls = cfg.urls || {};
 
@@ -11,7 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
         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>
+                    <div class="toast-body">${esc(message)}</div>
                     <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
                 </div>
             </div>`;
@@ -33,7 +39,7 @@ document.addEventListener('DOMContentLoaded', () => {
     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>`;
+        return `<span class="badge ${cls}">${esc(type)}</span>`;
     }
 
     // ─── Render table ────────────────────────────────────────────────────────
@@ -45,11 +51,11 @@ document.addEventListener('DOMContentLoaded', () => {
         }
 
         const rows = labels.map(l => {
-            const color     = l.color || '#6c757d';
+            const color     = safeColor(l.color);
             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>`
+                ? `<i class="fa ${esc(l.icon)} me-1"></i><span class="text-muted small">${esc(l.icon)}</span>`
                 : '<span class="text-muted small">—</span>';
             const activeBadge = l.is_active
                 ? '<span class="badge bg-success">активен</span>'
@@ -65,7 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
             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><strong>${esc(l.name)}</strong></td>
                     <td>${channelBadge(l.channel_type)}</td>
                     <td>${icon}</td>
                     <td class="text-center fw-bold">${l.product_count}</td>
@@ -81,7 +87,7 @@ document.addEventListener('DOMContentLoaded', () => {
                             <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}">
+                            title="${deleteTitle}" data-id="${l.id}" data-name="${esc(l.name)}">
                             <i class="fa fa-trash"></i>
                         </button>
                     </td>
@@ -185,7 +191,7 @@ document.addEventListener('DOMContentLoaded', () => {
             color:        color || null,
             icon:         $('#labelIcon').val().trim() || null,
             is_active:    $('#labelIsActive').is(':checked') ? 1 : 0,
-            _csrf:        yii.getCsrfToken(),
+            _csrf:        document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
         };
 
         $(this).prop('disabled', true).text('Сохранение...');
@@ -215,7 +221,7 @@ document.addEventListener('DOMContentLoaded', () => {
     });
 
     function toggleActive(id, force) {
-        $.post(urls.toggleActive, { id, force: force ? 1 : 0, _csrf: yii.getCsrfToken() }, res => {
+        $.post(urls.toggleActive, { id, force: force ? 1 : 0, _csrf: document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' }, res => {
             if (res.success) {
                 showToast(res.message, 'success');
                 loadLabels();
@@ -245,7 +251,7 @@ document.addEventListener('DOMContentLoaded', () => {
         const name = $(this).data('name');
         if (!confirm(`Удалить лейбл «${name}»?`)) return;
 
-        $.post(urls.delete, { id, _csrf: yii.getCsrfToken() }, res => {
+        $.post(urls.delete, { id, _csrf: document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' }, res => {
             if (res.success) {
                 showToast(res.message, 'success');
                 loadLabels();
index 680cf6583286ffbca439b117bdeaf1f877b693c7..67a8214a057db74e0a476899de53a2648a9d7918 100644 (file)
@@ -1,5 +1,11 @@
 document.addEventListener('DOMContentLoaded', () => {
 
+    const esc = s => s == null ? '' : String(s)
+        .replace(/&/g, '&amp;').replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+
+    const safeColor = c => /^#[0-9A-Fa-f]{6}$/.test(c) ? c : '#6c757d';
+
     // ─── Фильтры ────────────────────────────────────────────────────────────
 
     $('.clear-btn').on('click', function () {
@@ -43,7 +49,7 @@ document.addEventListener('DOMContentLoaded', () => {
         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>
+                    <div class="toast-body">${esc(message)}</div>
                     <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
                 </div>
             </div>`;
@@ -62,7 +68,7 @@ document.addEventListener('DOMContentLoaded', () => {
         }
         return intervals.map(iv => {
             const cls = iv.status === 'active' ? 'active' : iv.status === 'future' ? 'future' : 'past';
-            return `<span class="pr-int ${cls}">${iv.label}</span>`;
+            return `<span class="pr-int ${cls}">${esc(iv.label)}</span>`;
         }).join('');
     }
 
@@ -72,6 +78,26 @@ document.addEventListener('DOMContentLoaded', () => {
         const hasActive  = intervals.some(iv => iv.status === 'active');
         const hasAny     = intervals.length > 0;
         const hasFuture  = intervals.some(iv => iv.status === 'future');
+
+        let badgeBg, badgeColor, badgeText, titleHtml;
+        if (hasActive) {
+            badgeBg = '#d4edda'; badgeColor = '#0f5132'; badgeText = 'Актуален';
+            titleHtml = 'Предстоящие интервалы';
+        } else if (hasFuture) {
+            badgeBg = '#cfe2ff'; badgeColor = '#084298'; badgeText = 'Будущий';
+            titleHtml = 'Предстоящие интервалы';
+        } else if (hasAny) {
+            badgeBg = '#e9ecef'; badgeColor = '#6c757d'; badgeText = 'Не актуален';
+            titleHtml = 'Предстоящие интервалы';
+        } else {
+            badgeBg = '#f8d7da'; badgeColor = '#842029';
+            badgeText = '<i class="fa fa-exclamation-triangle" style="font-size:8px"></i> Нет интервалов';
+            titleHtml = '<span class="text-danger">Нет интервалов</span>';
+        }
+
+        $card.find('.pr-status-badge').css({ background: badgeBg, color: badgeColor }).html(badgeText);
+        $card.find('.pr-intervals-title').html(titleHtml);
+
         if (!hasAny) {
             $card.css('border-left', '4px solid #dc3545').css('opacity', '');
         } else if (!hasActive && !hasFuture) {
@@ -89,7 +115,7 @@ document.addEventListener('DOMContentLoaded', () => {
         return labels.map(l => {
             const cls  = CHANNEL_CLASS[l.channel_type] || 'ch-1p';
             const icon = CHANNEL_ICON[l.channel_type]  || 'fa-tag';
-            return `<span class="ch ${cls} ed label-chip" data-label-id="${l.id}"><i class="fa ${icon}" style="font-size:9px;margin-right:3px"></i>${l.name}<button type="button" class="remove-label-chip ch-rm" data-guid="${guid}" data-label-id="${l.id}">&times;</button></span>`;
+            return `<span class="ch ${cls} ed label-chip" data-label-id="${l.id}"><i class="fa ${icon}" style="font-size:9px;margin-right:3px"></i>${esc(l.name)}<button type="button" class="remove-label-chip ch-rm" data-guid="${guid}" data-label-id="${l.id}">&times;</button></span>`;
         }).join('');
     }
 
@@ -103,6 +129,68 @@ document.addEventListener('DOMContentLoaded', () => {
     const urls     = cfg.urls || {};
     const months   = cfg.months || {};
 
+    // ─── Filter cascade helpers ──────────────────────────────────────────────
+
+    function populateFilterSelect($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));
+        }
+    }
+
+    function subcategoriesFor(category) {
+        if (!category) return [];
+        return (cfg.subcategoriesByCategory || {})[category] || [];
+    }
+
+    function speciesFor(subcategory) {
+        if (!subcategory) return [];
+        return (cfg.speciesBySubcategory || {})[subcategory] || [];
+    }
+
+    function attrsFor(bySpecies, bySub, byCat, species, subcategory, category) {
+        if (species    && bySpecies[species])       return bySpecies[species];
+        if (subcategory && bySub[subcategory])      return bySub[subcategory];
+        if (category   && byCat[category])          return byCat[category];
+        return null;
+    }
+
+    function repopulateAttrs() {
+        const cat = $('#filter-category').val();
+        const sub = $('#filter-subcategory').val();
+        const sp  = $('#filter-species').val();
+
+        const sorts  = attrsFor(cfg.sortsBySpecies  || {}, cfg.sortsBySubcategory  || {}, cfg.sortsByCategory  || {}, sp, sub, cat);
+        const colors = attrsFor(cfg.colorsBySpecies || {}, cfg.colorsBySubcategory || {}, cfg.colorsByCategory || {}, sp, sub, cat);
+        const sizes  = attrsFor(cfg.sizesBySpecies  || {}, cfg.sizesBySubcategory  || {}, cfg.sizesByCategory  || {}, sp, sub, cat);
+
+        populateFilterSelect($('#filter-sort'),  sorts  !== null ? sorts  : (cfg.allSorts  || []), $('#filter-sort').val());
+        populateFilterSelect($('#filter-color'), colors !== null ? colors : (cfg.allColors || []), $('#filter-color').val());
+        populateFilterSelect($('#filter-size'),  sizes  !== null ? sizes  : (cfg.allSizes  || []), $('#filter-size').val());
+    }
+
+    $('#filter-category').on('change', function () {
+        const $sub = $('#filter-subcategory');
+        populateFilterSelect($sub, subcategoriesFor($(this).val()), $sub.val());
+        populateFilterSelect($('#filter-species'), speciesFor($sub.val()), $('#filter-species').val());
+        repopulateAttrs();
+    });
+
+    $('#filter-subcategory').on('change', function () {
+        populateFilterSelect($('#filter-species'), speciesFor($(this).val()), $('#filter-species').val());
+        repopulateAttrs();
+    });
+
+    $('#filter-species').on('change', function () {
+        repopulateAttrs();
+    });
+
     const $modal          = $('#intervalsModal');
     const $tableContainer = $('#intervalsTableContainer');
     const $intervalForm   = $('#intervalForm');
@@ -155,7 +243,7 @@ document.addEventListener('DOMContentLoaded', () => {
             return `
                 <tr>
                     <td class="text-center">${i + 1}</td>
-                    <td${style}>${iv.label}</td>
+                    <td${style}>${esc(iv.label)}</td>
                     <td>${statusBadge(iv.status)}</td>
                     <td>
                         <button class="btn btn-xs btn-outline-primary edit-interval-btn me-1"
@@ -209,13 +297,13 @@ document.addEventListener('DOMContentLoaded', () => {
             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>`;
+                const badge   = `<span class="badge me-2" style="background-color:${safeColor(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>` : ''}
+                        ${badge}${esc(l.name)}
+                        ${l.channel_type ? `<span class="text-muted small ms-2">(${esc(l.channel_type)})</span>` : ''}
                     </label>
                 </div>`;
             }).join('');
@@ -273,10 +361,10 @@ document.addEventListener('DOMContentLoaded', () => {
             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 label    = [m.supplier_name, m.plantation_name, m.supplier_product_name].filter(Boolean).map(s => esc(s)).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>` : '';
+            const comment  = m.comment ? `<p class="text-muted small mb-1">${esc(m.comment)}</p>` : '';
+            const editedAt = m.km_comment_at ? `<span class="text-muted small">Изменено: ${esc(m.km_comment_at)}</span>` : '';
 
             return `
             <div class="accordion-item ${rowCls}" data-mapping-id="${m.id}">
@@ -294,7 +382,7 @@ document.addEventListener('DOMContentLoaded', () => {
                         ${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>
+                                  data-mapping-id="${m.id}">${esc(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}">
                                 Сохранить
@@ -413,7 +501,6 @@ document.addEventListener('DOMContentLoaded', () => {
         $('#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');
@@ -421,7 +508,7 @@ document.addEventListener('DOMContentLoaded', () => {
         $('#avgScoreBar').hide();
         $('#testMappingBlock').hide();
         loadIntervals(currentGuid);
-        $modal.modal('show');
+        bootstrap.Modal.getOrCreateInstance($modal[0]).show();
     });
 
     $modal.on('hidden.bs.modal', function () {
index 915f7fd97e0893203309cbb3133ef99675b31887..d9f8850677409ce7469021487f9df01863fbbcec 100644 (file)
@@ -1,5 +1,9 @@
 document.addEventListener('DOMContentLoaded', () => {
 
+    const esc = s => s == null ? '' : String(s)
+        .replace(/&/g, '&amp;').replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+
     const cfg  = window.markupConfig || {};
     const urls = cfg.urls || {};
 
@@ -11,7 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
         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>
+                    <div class="toast-body">${esc(message)}</div>
                     <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
                 </div>
             </div>`;
@@ -22,78 +26,28 @@ document.addEventListener('DOMContentLoaded', () => {
         el.addEventListener('hidden.bs.toast', () => el.remove());
     }
 
-    // ─── Row status helpers ─────────────────────────────────────────────────
+    // ─── Helpers для строк номенклатуры (not_needed / approved) ─────────────
 
     const STATUS_BADGE = {
-        not_needed:   'secondary',
-        unclassified: 'danger',
-        pending:      'warning text-dark',
-        approved:     'success',
+        not_needed: 'secondary',
+        approved:   'success',
     };
 
     const STATUS_LABEL = {
-        not_needed:   'Не нужна',
-        unclassified: 'Не размечен',
-        pending:      'Подтвердить',
-        approved:     'Подтверждён',
-    };
-
-    const STATUS_ROW_STYLE = {
-        not_needed:   'background:#f8f9fa;opacity:.75',
-        unclassified: 'background:#fff5f5',
-        approved:     '',
+        not_needed: 'Не нужна',
+        approved:   'Подтверждён',
     };
 
-    function rowStyleForPending(conf) {
-        if (conf >= 90) return '';
-        if (conf >= 70) return 'background:#fffbeb';
-        return 'background:#fff5f5';
-    }
-
-    function applyRowStatus($row, status, conf) {
+    function applyRowStatus($row, status) {
         $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('.subcategory-cell').text(product.subcategory || '—');
         $row.find('.species-cell').text(product.species || '—');
         $row.find('.sort-cell').text(product.sort || '—');
         $row.find('.color-cell').text(product.color || '—');
@@ -101,26 +55,55 @@ document.addEventListener('DOMContentLoaded', () => {
 
         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     || '',
+            'data-category':    product.category    || '',
+            'data-subcategory': product.subcategory || '',
+            'data-species':     product.species     || '',
+            'data-sort':        product.sort        || '',
+            'data-color':       product.color       || '',
+            'data-size':        product.size        || '',
         });
     }
 
-    // ─── Individual: Approve ────────────────────────────────────────────────
+    // ─── Counter helpers ────────────────────────────────────────────────────
+
+    function adjustCounters(pendingDelta, approvedDelta) {
+        const pending = document.getElementById('markupCntPending');
+        if (pending) pending.textContent = Math.max(0, (parseInt(pending.textContent) || 0) + pendingDelta);
+
+        const approved = document.getElementById('markupCntApproved');
+        if (approved) approved.textContent = Math.max(0, (parseInt(approved.textContent) || 0) + approvedDelta);
+
+        const badge = document.getElementById('markupTabBadge');
+        if (badge) badge.textContent = Math.max(0, (parseInt(badge.textContent) || 0) + pendingDelta);
+    }
+
+    // ─── Cascade helpers ────────────────────────────────────────────────────
+
+    function subcategoriesFor(category) {
+        if (!category) return cfg.subcategoryList || [];
+        return (cfg.subcategoriesByCategory || {})[category] || [];
+    }
+
+    function speciesFor(subcategory) {
+        if (!subcategory) return cfg.speciesList || [];
+        return (cfg.speciesBySubcategory || {})[subcategory] || [];
+    }
+
+    // ─── Approve prediction (строка исчезает из очереди) ────────────────────
 
-    $(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}"]`);
+    $(document).on('click', '.markup-approve-prediction-btn', function () {
+        const $btn   = $(this).prop('disabled', true);
+        const predId = $btn.attr('data-prediction-id');
+        const $row   = $(`tr[data-prediction-id="${predId}"]`);
 
-        $.post(urls.approve, { guid, _csrf: yii.getCsrfToken() })
+        $.post(urls.approvePrediction, { prediction_id: predId, _csrf: yii.getCsrfToken() })
             .done(resp => {
                 if (resp.success) {
-                    applyRowStatus($row, 'approved', null);
-                    showToast('Подтверждено', 'success');
+                    $row.fadeOut(300, function () { $(this).remove(); });
+                    selectedIds.delete(predId);
+                    updateMarkupBulkBar();
+                    adjustCounters(-1, 1);
+                    showToast('Разметка подтверждена и применена', 'success');
                 } else {
                     showToast(resp.message || 'Ошибка');
                     $btn.prop('disabled', false);
@@ -132,7 +115,7 @@ document.addEventListener('DOMContentLoaded', () => {
             });
     });
 
-    // ─── Individual: Not needed ─────────────────────────────────────────────
+    // ─── Not needed (только для номенклатуры) ───────────────────────────────
 
     $(document).on('click', '.markup-not-needed-btn', function () {
         const $btn = $(this).prop('disabled', true);
@@ -142,7 +125,9 @@ document.addEventListener('DOMContentLoaded', () => {
         $.post(urls.notNeeded, { guid, _csrf: yii.getCsrfToken() })
             .done(resp => {
                 if (resp.success) {
-                    applyRowStatus($row, 'not_needed', null);
+                    $row.fadeOut(300, function () { $(this).remove(); });
+                    selectedIds.delete(guid);
+                    updateMarkupBulkBar();
                     showToast('Помечено: разметка не нужна', 'success');
                 } else {
                     showToast(resp.message || 'Ошибка');
@@ -179,8 +164,7 @@ document.addEventListener('DOMContentLoaded', () => {
         $.post(urls.revertMarkup, { guid, reason, _csrf: yii.getCsrfToken() })
             .done(resp => {
                 if (resp.success) {
-                    const $row = $(`tr[data-guid="${guid}"]`);
-                    applyRowStatus($row, 'unclassified', null);
+                    $(`tr[data-guid="${guid}"]`).fadeOut(300, function () { $(this).remove(); });
                     bootstrap.Modal.getInstance(document.getElementById('markupRevertModal')).hide();
                     showToast('Возвращено в разметку', 'success');
                 } else {
@@ -206,51 +190,112 @@ document.addEventListener('DOMContentLoaded', () => {
         }
     }
 
-    $(document).on('click', '.markup-edit-btn', function () {
+    function openEditModal(data) {
+        const { predictionId, guid, name, category, subcategory, species, sort, color, size } = data;
+
+        $('#markupEditPredictionId').val(predictionId || '');
+        $('#markupEditGuid').val(guid || '');
+        $('#markupEditName').text(name || '');
+
+        populateSelect($('#markupEditCategory'),    cfg.categories || [],       category || '');
+        populateSelect($('#markupEditSubcategory'), subcategoriesFor(category), subcategory || '');
+        populateSelect($('#markupEditSpecies'),     speciesFor(subcategory),    species || '');
+        populateSelect($('#markupEditSort'),        cfg.sorts      || [],       sort  || '');
+        populateSelect($('#markupEditColor'),       cfg.colors     || [],       color || '');
+        $('#markupEditSize').val(size || '');
+
+        const title = predictionId
+            ? '<i class="fa fa-tag me-2"></i>Подтвердить предсказание'
+            : '<i class="fa fa-tag me-2"></i>Редактировать разметку';
+        $('#markupEditModalLabel').html(title);
+
+        bootstrap.Modal.getOrCreateInstance(document.getElementById('markupEditModal')).show();
+    }
+
+    // Кнопка редактирования для предсказания
+    $(document).on('click', '.markup-edit-prediction-btn', function () {
         const $btn = $(this);
+        openEditModal({
+            predictionId: $btn.attr('data-prediction-id'),
+            name:         $btn.attr('data-name')         || '',
+            category:     $btn.attr('data-category')     || '',
+            subcategory:  $btn.attr('data-subcategory')  || '',
+            species:      $btn.attr('data-species')      || '',
+            sort:         $btn.attr('data-sort')         || '',
+            color:        $btn.attr('data-color')        || '',
+            size:         $btn.attr('data-size')         || '',
+        });
+    });
 
-        $('#markupEditGuid').val($btn.attr('data-guid'));
-        $('#markupEditName').text($btn.attr('data-name') || '');
+    // Кнопка редактирования для записи номенклатуры
+    $(document).on('click', '.markup-edit-btn', function () {
+        const $btn = $(this);
+        openEditModal({
+            guid:        $btn.attr('data-guid'),
+            name:        $btn.attr('data-name')        || '',
+            category:    $btn.attr('data-category')    || '',
+            subcategory: $btn.attr('data-subcategory') || '',
+            species:     $btn.attr('data-species')     || '',
+            sort:        $btn.attr('data-sort')        || '',
+            color:       $btn.attr('data-color')       || '',
+            size:        $btn.attr('data-size')        || '',
+        });
+    });
 
-        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')    || '');
+    // ─── Edit modal cascade ─────────────────────────────────────────────────
 
-        $('#markupEditSize').val($btn.attr('data-size') || '');
+    $('#markupEditCategory').on('change', function () {
+        const category = $(this).val();
+        populateSelect($('#markupEditSubcategory'), subcategoriesFor(category), '');
+        populateSelect($('#markupEditSpecies'),     speciesFor(''),             '');
+    });
 
-        const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('markupEditModal'));
-        modal.show();
+    $('#markupEditSubcategory').on('change', function () {
+        populateSelect($('#markupEditSpecies'), speciesFor($(this).val()), '');
     });
 
-    $('#markupEditSaveBtn').on('click', function () {
-        const $btn = $(this).prop('disabled', true);
-        const guid = $('#markupEditGuid').val();
+    // ─── Edit modal: сохранить (prediction или nomenclature) ────────────────
 
+    $('#markupEditSaveBtn').on('click', function () {
         if (!$('#markupEditCategory').val()) {
             showToast('Категория обязательна');
-            $btn.prop('disabled', false);
             return;
         }
 
+        const $btn       = $(this).prop('disabled', true);
+        const predId     = $('#markupEditPredictionId').val();
+        const guid       = $('#markupEditGuid').val();
+        const isPred     = !!predId;
+        const url        = isPred ? urls.savePrediction : urls.saveMarkup;
+        const idKey      = isPred ? 'prediction_id' : 'guid';
+        const idVal      = isPred ? predId : guid;
+
         const data = {
-            guid,
-            category: $('#markupEditCategory').val(),
-            species:  $('#markupEditSpecies').val(),
-            sort:     $('#markupEditSort').val(),
-            color:    $('#markupEditColor').val(),
-            size:     $('#markupEditSize').val(),
-            _csrf:    yii.getCsrfToken(),
+            [idKey]:     idVal,
+            category:    $('#markupEditCategory').val(),
+            subcategory: $('#markupEditSubcategory').val(),
+            species:     $('#markupEditSpecies').val(),
+            sort:        $('#markupEditSort').val(),
+            color:       $('#markupEditColor').val(),
+            size:        $('#markupEditSize').val(),
+            _csrf:       yii.getCsrfToken(),
         };
 
-        $.post(urls.saveMarkup, data)
+        $.post(url, data)
             .done(resp => {
                 if (resp.success) {
-                    const $row = $(`tr[data-guid="${guid}"]`);
-                    updateRowFields($row, resp.product);
-                    applyRowStatus($row, resp.product.classification_status, null);
+                    if (isPred) {
+                        $(`tr[data-prediction-id="${predId}"]`).fadeOut(300, function () { $(this).remove(); });
+                        selectedIds.delete(predId);
+                        updateMarkupBulkBar();
+                        adjustCounters(-1, 1);
+                    } else {
+                        const $row = $(`tr[data-guid="${guid}"]`);
+                        updateRowFields($row, resp.product);
+                        applyRowStatus($row, resp.product.classification_status);
+                    }
                     bootstrap.Modal.getInstance(document.getElementById('markupEditModal')).hide();
-                    showToast('Сохранено и подтверждено', 'success');
+                    showToast(resp.message || 'Сохранено', 'success');
                 } else {
                     showToast(resp.message || 'Ошибка сохранения');
                 }
@@ -261,36 +306,26 @@ document.addEventListener('DOMContentLoaded', () => {
 
     // ─── Bulk selection ─────────────────────────────────────────────────────
 
-    const selectedGuids = new Set();
+    const selectedIds = new Set();
 
     function updateMarkupBulkBar() {
-        const count = selectedGuids.size;
+        const count = selectedIds.size;
         const $bar  = $('#markupBulkBar');
-
-        if (count > 0) {
-            $bar.removeClass('d-none');
-        } else {
-            $bar.addClass('d-none');
-        }
-
+        count > 0 ? $bar.removeClass('d-none') : $bar.addClass('d-none');
         $('#markupBulkCount').text(count + ' выбрано');
 
-        const allCbs    = document.querySelectorAll('.markup-row-cb');
+        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;
+        const $sel       = $('#markupSelectAll')[0];
+        if ($sel) {
+            $sel.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length;
+            $sel.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);
-        }
+        const val = $(this).val();
+        this.checked ? selectedIds.add(val) : selectedIds.delete(val);
         updateMarkupBulkBar();
     });
 
@@ -298,39 +333,46 @@ document.addEventListener('DOMContentLoaded', () => {
         const checked = this.checked;
         $('.markup-row-cb').each(function () {
             this.checked = checked;
-            if (checked) {
-                selectedGuids.add($(this).val());
-            } else {
-                selectedGuids.delete($(this).val());
-            }
+            checked ? selectedIds.add($(this).val()) : selectedIds.delete($(this).val());
         });
         updateMarkupBulkBar();
     });
 
     $('#markupBulkClearBtn').on('click', () => {
-        selectedGuids.clear();
+        selectedIds.clear();
         $('.markup-row-cb, #markupSelectAll').prop('checked', false);
         updateMarkupBulkBar();
     });
 
-    // ─── Bulk approve ───────────────────────────────────────────────────────
+    // ─── Bulk approve (prediction или nomenclature) ─────────────────────────
 
     $('#markupBulkApproveBtn').on('click', function () {
-        if (!selectedGuids.size) return;
+        if (!selectedIds.size) return;
 
-        const $btn = $(this).prop('disabled', true);
-        const guids = Array.from(selectedGuids);
+        const $btn    = $(this).prop('disabled', true);
+        const ids     = Array.from(selectedIds);
+        const isPred  = cfg.dataSource === 'predictions';
+        const url     = isPred ? urls.bulkApprovePredictions : urls.bulkApprove;
+        const param   = isPred ? 'prediction_ids' : 'guids';
 
-        $.post(urls.bulkApprove, { guids, _csrf: yii.getCsrfToken() })
+        $.post(url, { [param]: ids, _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);
+                    ids.forEach(id => {
+                        if (isPred) {
+                            $(`tr[data-prediction-id="${id}"]`).fadeOut(300, function () { $(this).remove(); });
+                        } else {
+                            const $row = $(`tr[data-guid="${id}"]`);
+                            if ($row.attr('data-status') === 'pending') {
+                                applyRowStatus($row, 'approved');
+                            }
                         }
                     });
-                    selectedGuids.clear();
+                    if (isPred) {
+                        const applied = resp.applied ?? ids.length;
+                        adjustCounters(-applied, applied);
+                    }
+                    selectedIds.clear();
                     $('.markup-row-cb, #markupSelectAll').prop('checked', false);
                     updateMarkupBulkBar();
                     showToast(resp.message || 'Готово', 'success');
@@ -342,4 +384,17 @@ document.addEventListener('DOMContentLoaded', () => {
             .always(() => $btn.prop('disabled', false));
     });
 
+    // ─── Filter: cascade category → subcategory → species ───────────────────
+
+    $('#dynamicmodel-category').on('change', function () {
+        const $sub = $('#dynamicmodel-subcategory');
+        populateSelect($sub, subcategoriesFor($(this).val()), $sub.val());
+        populateSelect($('#dynamicmodel-species'), speciesFor($sub.val()), $('#dynamicmodel-species').val());
+    });
+
+    $('#dynamicmodel-subcategory').on('change', function () {
+        const $sp = $('#dynamicmodel-species');
+        populateSelect($sp, speciesFor($(this).val()), $sp.val());
+    });
+
 });