From f573697c700f7b7a7a4d03875da59e42bbd43eb8 Mon Sep 17 00:00:00 2001 From: fomichev Date: Thu, 14 May 2026 09:55:05 +0300 Subject: [PATCH] =?utf8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- ...ducts1cNomenclatureActualityController.php | 200 +++++-- ...Products1cNomenclatureMarkupController.php | 482 ++++++++++++++--- ...6_120000_add_automark_rbac_permissions.php | 6 +- erp24/records/AdminGroup.php | 4 +- erp24/records/Products1cNomenclature.php | 7 + erp24/services/AutoMarkService.php | 20 +- erp24/traits/AuditableTrait.php | 2 +- erp24/views/assortment-label/index.php | 2 +- .../index.php | 143 ++--- .../products1c-nomenclature-markup/index.php | 499 ++++++++++++------ ...select2-container-and-other-styles-fix.css | 10 +- erp24/web/js/assortmentLabel/index.js | 24 +- .../products1cNomenclatureActuality/index.js | 113 +++- .../js/products1cNomenclatureMarkup/index.js | 329 +++++++----- 14 files changed, 1280 insertions(+), 561 deletions(-) diff --git a/erp24/controllers/Products1cNomenclatureActualityController.php b/erp24/controllers/Products1cNomenclatureActualityController.php index a1517ad3..71d08659 100644 --- a/erp24/controllers/Products1cNomenclatureActualityController.php +++ b/erp24/controllers/Products1cNomenclatureActualityController.php @@ -1,5 +1,7 @@ [ - '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'); diff --git a/erp24/controllers/Products1cNomenclatureMarkupController.php b/erp24/controllers/Products1cNomenclatureMarkupController.php index 1e832906..1b0b1796 100644 --- a/erp24/controllers/Products1cNomenclatureMarkupController.php +++ b/erp24/controllers/Products1cNomenclatureMarkupController.php @@ -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; + } } diff --git a/erp24/migrations/m260506_120000_add_automark_rbac_permissions.php b/erp24/migrations/m260506_120000_add_automark_rbac_permissions.php index add9e22b..48264147 100644 --- a/erp24/migrations/m260506_120000_add_automark_rbac_permissions.php +++ b/erp24/migrations/m260506_120000_add_automark_rbac_permissions.php @@ -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 ]; diff --git a/erp24/records/AdminGroup.php b/erp24/records/AdminGroup.php index 104245f9..38ad4e16 100755 --- a/erp24/records/AdminGroup.php +++ b/erp24/records/AdminGroup.php @@ -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 и логики грейдов) diff --git a/erp24/records/Products1cNomenclature.php b/erp24/records/Products1cNomenclature.php index 8b787dd8..bd5c369e 100644 --- a/erp24/records/Products1cNomenclature.php +++ b/erp24/records/Products1cNomenclature.php @@ -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; /** diff --git a/erp24/services/AutoMarkService.php b/erp24/services/AutoMarkService.php index 1f72cf3b..cf2d4417 100644 --- a/erp24/services/AutoMarkService.php +++ b/erp24/services/AutoMarkService.php @@ -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)) { diff --git a/erp24/traits/AuditableTrait.php b/erp24/traits/AuditableTrait.php index 12358774..adcbea01 100644 --- a/erp24/traits/AuditableTrait.php +++ b/erp24/traits/AuditableTrait.php @@ -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); diff --git a/erp24/views/assortment-label/index.php b/erp24/views/assortment-label/index.php index 0a07475e..8a8f28ef 100644 --- a/erp24/views/assortment-label/index.php +++ b/erp24/views/assortment-label/index.php @@ -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; } diff --git a/erp24/views/products1c-nomenclature-actuality/index.php b/erp24/views/products1c-nomenclature-actuality/index.php index c0c49297..de6a8174 100644 --- a/erp24/views/products1c-nomenclature-actuality/index.php +++ b/erp24/views/products1c-nomenclature-actuality/index.php @@ -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) ?> -
- -
- field($filter, 'onlyActive', ['options' => ['class' => 'mb-0 me-1']]) - ->checkbox(['label' => 'Только активные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyActive, 'id' => 'onlyActiveCheckbox']) - ->label(false) ?> - field($filter, 'onlyInactive', ['options' => ['class' => 'mb-0']]) - ->checkbox(['label' => 'Только неактивные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyInactive, 'id' => 'onlyInactiveCheckbox']) - ->label(false) ?> -
+
+ field($filter, 'onlyActive', ['options' => ['class' => 'mb-0']]) + ->checkbox(['label' => 'Только активные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyActive, 'id' => 'onlyActiveCheckbox']) + ->label(false) ?> + field($filter, 'onlyInactive', ['options' => ['class' => 'mb-0']]) + ->checkbox(['label' => 'Только неактивные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyInactive, 'id' => 'onlyInactiveCheckbox']) + ->label(false) ?>
@@ -436,7 +351,7 @@ function actConceptChips(array $names): string style="width:16px;height:16px;flex-shrink:0;margin-top:4px" id="rc-id) ?>">
- +
name) ?>
GUID: id, 0, 8)) ?>… @@ -449,7 +364,7 @@ function actConceptChips(array $names): string
- + Нет интервалов' : 'Предстоящие интервалы' ?>
@@ -636,7 +551,21 @@ function actConceptChips(array $names): string diff --git a/erp24/web/css/select2-container-and-other-styles-fix.css b/erp24/web/css/select2-container-and-other-styles-fix.css index 596af7ef..1daab10b 100755 --- a/erp24/web/css/select2-container-and-other-styles-fix.css +++ b/erp24/web/css/select2-container-and-other-styles-fix.css @@ -122,4 +122,12 @@ 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 diff --git a/erp24/web/js/assortmentLabel/index.js b/erp24/web/js/assortmentLabel/index.js index 59068ebb..7195e7d3 100644 --- a/erp24/web/js/assortmentLabel/index.js +++ b/erp24/web/js/assortmentLabel/index.js @@ -1,5 +1,11 @@ document.addEventListener('DOMContentLoaded', () => { + const esc = s => s == null ? '' : String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + + 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 = ` `; @@ -33,7 +39,7 @@ document.addEventListener('DOMContentLoaded', () => { function channelBadge(type) { if (!type) return '—'; const cls = CHANNEL_CLASS[type] || 'bg-secondary'; - return `${type}`; + return `${esc(type)}`; } // ─── 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 = ``; const icon = l.icon - ? `${l.icon}` + ? `${esc(l.icon)}` : '—'; const activeBadge = l.is_active ? 'активен' @@ -65,7 +71,7 @@ document.addEventListener('DOMContentLoaded', () => { return ` ${colorDot} - ${l.name} + ${esc(l.name)} ${channelBadge(l.channel_type)} ${icon} ${l.product_count} @@ -81,7 +87,7 @@ document.addEventListener('DOMContentLoaded', () => { @@ -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(); diff --git a/erp24/web/js/products1cNomenclatureActuality/index.js b/erp24/web/js/products1cNomenclatureActuality/index.js index 680cf658..67a8214a 100644 --- a/erp24/web/js/products1cNomenclatureActuality/index.js +++ b/erp24/web/js/products1cNomenclatureActuality/index.js @@ -1,5 +1,11 @@ document.addEventListener('DOMContentLoaded', () => { + const esc = s => s == null ? '' : String(s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + + 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 = ` `; @@ -62,7 +68,7 @@ document.addEventListener('DOMContentLoaded', () => { } return intervals.map(iv => { const cls = iv.status === 'active' ? 'active' : iv.status === 'future' ? 'future' : 'past'; - return `${iv.label}`; + return `${esc(iv.label)}`; }).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 = ' Нет интервалов'; + titleHtml = 'Нет интервалов'; + } + + $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 `${l.name}`; + return `${esc(l.name)}`; }).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 = $('
`; @@ -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(`${label}`); - - 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( - `` - ); - } - if (!$notBtn.length) { - $row.find('.markup-edit-btn').after( - `` - ); - } - } else if (status === 'unclassified') { - $approveBtn.remove(); - if (!$notBtn.length) { - $row.find('.markup-edit-btn').after( - `` - ); - } - } 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 + ? 'Подтвердить предсказание' + : 'Редактировать разметку'; + $('#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()); + }); + }); -- 2.39.5