<?php
+declare(strict_types=1);
+
namespace app\controllers;
use Yii;
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;
/**
],
],
'verbs' => [
- 'class' => VerbFilter::className(),
+ 'class' => VerbFilter::class,
'actions' => [
'delete' => ['POST'],
'ajax-save-interval' => ['POST'],
$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) {
]);
}
- $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', '']);
->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()
}
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']),
'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]); }
}
$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');
$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);
}
$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++;
}
* Закрываем старую запись и создаем новую при изменении диапазона.
* @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;
}
}
}
}
+ $transaction->commit();
+ } catch (\Throwable $e) {
+ $transaction->rollBack();
+ Yii::error('processBatchActuality failed: ' . $e->getMessage(), 'actuality');
+ throw $e;
+ }
}
public function actionAjaxIntervals(): array
}
}
} 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();
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');
? 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,
/** @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),
];
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');
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 => 'Не размечены',
];
}
'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'],
],
[
'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'],
],
],
]);
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()
->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;
['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,
];
}
'guid' => $p->id,
'name' => $p->name,
'category' => $p->category,
+ 'subcategory' => $p->subcategory,
'species' => $p->species,
'sort' => $p->sort,
'color' => $p->color,
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;
$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,
];
}
+ // ── Вспомогательные методы ─────────────────────────────────────────────────
+
+ 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];
}
}
return $model;
}
+
+ private function findPrediction(int $id): Products1cAutomarkPrediction
+ {
+ $model = Products1cAutomarkPrediction::findOne($id);
+ if ($model === null) {
+ throw new NotFoundHttpException("Предсказание #{$id} не найдено");
+ }
+ return $model;
+ }
}
* - 1 (DIRECTOR)
* - 10 (GROUP_RS_DIRECTOR — Директор розничной сети)
* - 81 (GROUP_IT)
- * - 82 (GROUP_QC_MANAGER — Менеджер контроля качества)
+ * - 82 (GROUP_KIK_MANAGER — Менеджер контроля качества)
*
* После применения ОБЯЗАТЕЛЬНО запустить:
* php yii auth/init
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
];
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 и логики грейдов)
*/
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;
/**
public const RULE_THRESHOLD = 0.9;
public const SIMILARITY_THRESHOLD = 0.7;
- private const EXCLUDED_TYPES = [
+ public const EXCLUDED_TYPES = [
'[букет]',
'[сборка]',
'Матрица',
$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)) {
* Логирует создание/обновление.
* Для update пишет только реально изменённые атрибуты — не срабатывает на no-op save.
*/
- public function afterSave(bool $insert, array $changedAttributes): void
+ public function afterSave($insert, $changedAttributes): void
{
parent::afterSave($insert, $changedAttributes);
$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; }
/* @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 */
$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
{
->label(false) ?>
</div>
- <div class="fg align-self-end">
- <label> </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>
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: <?= Html::encode(substr($p->id, 0, 8)) ?>…
<!-- 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">
<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']) ?>',
/* @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; }
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">
<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">
</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']])
->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">
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>
</div>
</div>
-<!-- Модалка: Редактировать разметку -->
+<!-- Модалка: Редактировать разметку (используется для обоих источников) -->
<div class="modal fade" id="markupEditModal" tabindex="-1" aria-labelledby="markupEditModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
</div>
<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>
<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">
<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>
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
document.addEventListener('DOMContentLoaded', () => {
+ const esc = s => s == null ? '' : String(s)
+ .replace(/&/g, '&').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 || {};
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>`;
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 ────────────────────────────────────────────────────────
}
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>'
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>
<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>
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('Сохранение...');
});
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();
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();
document.addEventListener('DOMContentLoaded', () => {
+ const esc = s => s == null ? '' : String(s)
+ .replace(/&/g, '&').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 () {
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>`;
}
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('');
}
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) {
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}">×</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}">×</button></span>`;
}).join('');
}
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');
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"
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}"> </span>`;
+ const badge = `<span class="badge me-2" style="background-color:${safeColor(color)}"> </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('');
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}">
${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}">
Сохранить
$('#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');
$('#avgScoreBar').hide();
$('#testMappingBlock').hide();
loadIntervals(currentGuid);
- $modal.modal('show');
+ bootstrap.Modal.getOrCreateInstance($modal[0]).show();
});
$modal.on('hidden.bs.modal', function () {
document.addEventListener('DOMContentLoaded', () => {
+ const esc = s => s == null ? '' : String(s)
+ .replace(/&/g, '&').replace(/</g, '<')
+ .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+
const cfg = window.markupConfig || {};
const urls = cfg.urls || {};
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>`;
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 || '—');
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);
});
});
- // ─── Individual: Not needed ─────────────────────────────────────────────
+ // ─── Not needed (только для номенклатуры) ───────────────────────────────
$(document).on('click', '.markup-not-needed-btn', function () {
const $btn = $(this).prop('disabled', true);
$.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 || 'Ошибка');
$.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 {
}
}
- $(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 || 'Ошибка сохранения');
}
// ─── 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();
});
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');
.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());
+ });
+
});