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