From: fomichev Date: Wed, 22 Apr 2026 12:24:57 +0000 (+0300) Subject: Каталог категорийного менеджера X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=1f12c7e994aea57df3360335df7e4f6e7330e92d;p=erp24_rep%2Fyii-erp24%2F.git Каталог категорийного менеджера --- diff --git a/erp24/commands/Products1cConceptController.php b/erp24/commands/Products1cConceptController.php new file mode 100644 index 00000000..d90ad346 --- /dev/null +++ b/erp24/commands/Products1cConceptController.php @@ -0,0 +1,67 @@ +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, [':now' => $now])->execute(); + + $transaction->commit(); + + $this->stdout("Синхронизировано: {$inserted} концепций.\n"); + Yii::info("concept-sync: {$inserted} rows inserted", 'concept-sync'); + } catch (\Throwable $e) { + $transaction->rollBack(); + Yii::error('concept-sync failed: ' . $e->getMessage(), 'concept-sync'); + $this->stderr("Ошибка: " . $e->getMessage() . "\n"); + return ExitCode::UNSPECIFIED_ERROR; + } + + return ExitCode::OK; + } +} diff --git a/erp24/controllers/AssortmentLabelController.php b/erp24/controllers/AssortmentLabelController.php new file mode 100644 index 00000000..52a5eb8f --- /dev/null +++ b/erp24/controllers/AssortmentLabelController.php @@ -0,0 +1,205 @@ + [ + '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; + } +} diff --git a/erp24/controllers/Products1cNomenclatureActualityController.php b/erp24/controllers/Products1cNomenclatureActualityController.php index ec1a43d0..2fd7bd61 100644 --- a/erp24/controllers/Products1cNomenclatureActualityController.php +++ b/erp24/controllers/Products1cNomenclatureActualityController.php @@ -4,17 +4,23 @@ namespace app\controllers; use Yii; use yii\db\Query; +use yii\filters\AccessControl; use yii\web\BadRequestHttpException; +use yii_app\records\ProductConcept; use yii\web\Response; use yii_app\api3\modules\v1\models\Sales; use yii_app\records\Products1cAdditionalCharacteristics; use yii_app\records\Products1cNomenclature; +use yii_app\records\AssortmentLabel; +use yii_app\records\ProductAssortment; use yii_app\records\Products1cNomenclatureActuality; use yii_app\records\Products1cNomenclatureActualitySearch; use yii\web\Controller; use yii\web\NotFoundHttpException; use yii\filters\VerbFilter; use yii_app\records\Products1cPropType; +use yii_app\records\ProductScore; +use yii_app\records\StoreProductRating; /** * Products1cNomenclatureActualityController implements the CRUD actions for Products1cNomenclatureActuality model. @@ -29,10 +35,35 @@ class Products1cNomenclatureActualityController extends Controller return array_merge( parent::behaviors(), [ + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'allow' => true, + 'actions' => [ + 'ajax-save-interval', 'ajax-save-assortment', 'ajax-remove-label', + 'ajax-save-score', 'ajax-add-test-mapping', 'ajax-bulk-assign', + 'delete', 'create', 'update', 'ajax-delete', 'add-activity', + ], + 'permissions' => ['catmanager/manage'], + ], + [ + 'allow' => true, + 'actions' => ['index', 'view', 'export-xlsx', 'ajax-intervals', 'ajax-labels', 'ajax-scores'], + 'permissions' => ['catmanager/view'], + ], + ], + ], 'verbs' => [ 'class' => VerbFilter::className(), 'actions' => [ - 'delete' => ['POST'], + 'delete' => ['POST'], + 'ajax-save-interval' => ['POST'], + 'ajax-save-assortment' => ['POST'], + 'ajax-remove-label' => ['POST'], + 'ajax-save-score' => ['POST'], + 'ajax-add-test-mapping' => ['POST'], + 'ajax-bulk-assign' => ['POST'], ], ], ] @@ -50,29 +81,28 @@ class Products1cNomenclatureActualityController extends Controller 'category', 'subcategory', 'species', 'type', 'color', 'sort', 'size', 'date_from', 'date_to', + 'search', 'pageSize', 'sort_by', ]); $filter->addRule([ 'category', 'subcategory', 'species', 'type', 'color', 'sort', 'size', 'date_from', 'date_to', + 'search', 'sort_by', ], 'safe'); + $filter->addRule(['pageSize'], 'integer'); $filter->addRule(['onlyActive', 'onlyInactive'], 'boolean'); $filter->load(Yii::$app->request->get()); - - if (Yii::$app->request->isPost && $post = Yii::$app->request->post('actuality', [])) { - $this->processBatchActuality($post); - Yii::$app->session->setFlash('success', 'Данные по актуальности успешно сохранены.'); - return $this->refresh(); - } - + $pageSize = in_array((int)$filter->pageSize, [50, 100, 500]) ? (int)$filter->pageSize : 50; + $sortBy = in_array($filter->sort_by, ['name', 'date_actuality']) ? $filter->sort_by : 'name'; $emptyQuery = Products1cNomenclature::find()->where('0=1'); $dataProvider = new \yii\data\ActiveDataProvider([ 'query' => $emptyQuery, - 'pagination' => ['pageSize' => 50], + 'pagination' => ['pageSize' => $pageSize], 'sort' => ['defaultOrder' => ['name' => SORT_ASC]], ]); + $counters = null; $attrMap = [ 'type' => ['type', 'тип'], 'color' => ['цвет', 'color'], @@ -90,7 +120,8 @@ class Products1cNomenclatureActualityController extends Controller $filter->date_from, $filter->date_to, $filter->onlyActive, - $filter->onlyInactive + $filter->onlyInactive, + $filter->search, ]); if ($filtersUsed) { $query = Products1cNomenclature::find()->alias('n'); @@ -120,6 +151,10 @@ class Products1cNomenclatureActualityController extends Controller } } + if ($filter->search) { + $query->andWhere(['ilike', 'n.name', $filter->search]); + } + if (!empty($filter->onlyActive)) { $query->andWhere(['exists', Products1cNomenclatureActuality::find() @@ -156,11 +191,9 @@ class Products1cNomenclatureActualityController extends Controller ->where('a.guid = n.id'); if ($hasDateFrom && !$hasDateTo) { - $dateExists//->andWhere(['a.date_from' => $dateFrom]) - ->andWhere(['>=', 'a.date_to', $dateFrom]);; + $dateExists->andWhere(['>=', 'a.date_to', $dateFrom]); } elseif (!$hasDateFrom && $hasDateTo) { - $dateExists//->andWhere(['a.date_to' => $dateTo]) - ->andWhere(['<=', 'a.date_from', $dateTo]); + $dateExists->andWhere(['<=', 'a.date_from', $dateTo]); } else { $dateExists ->andWhere(['>=', 'a.date_to', $dateFrom]) @@ -172,58 +205,91 @@ class Products1cNomenclatureActualityController extends Controller } else { $query->andWhere(['exists', $dateExists->select(new \yii\db\Expression('1'))]); } - - $query->with(['actualities' => function ($subQuery) use ($hasDateFrom, $hasDateTo, $dateFrom, $dateTo) { - if ($hasDateFrom && !$hasDateTo) { - $subQuery//->andWhere(['date_from' => $dateFrom]) - ->andWhere(['>=', 'date_to', $dateFrom]); - } elseif (!$hasDateFrom && $hasDateTo) { - $subQuery//->andWhere(['date_to' => $dateTo]) - ->andWhere(['<=', 'date_from', $dateTo]); - } else { - $subQuery->andWhere(['>=', 'date_to', $dateFrom]) - ->andWhere(['<=', 'date_from', $dateTo]); - } - $subQuery->orderBy(['date_from' => SORT_ASC]); - }]); - } else { - $query->with(['actualities' => function ($q) { - $q->orderBy(['date_from' => SORT_ASC]); - }]); } - - - + $query->with(['actualities' => fn($q) => $q->orderBy(['date_from' => SORT_ASC])]); $products = $query->orderBy(['n.name' => SORT_ASC])->all(); $rows = []; foreach ($products as $product) { - $acts = $product->actualities; - if ($acts) { - foreach ($acts as $act) { - $rows[] = [ - 'product' => $product, - 'actuality' => $act, - ]; + $rows[] = [ + 'product' => $product, + 'actualities' => $product->actualities ?? [], + 'labels' => [], + 'concepts' => [], + ]; + } + + $guids = []; + if (!empty($rows)) { + $guids = array_map(static fn($r) => $r['product']->id, $rows); + $assortments = ProductAssortment::find() + ->with('label') + ->where(['product_guid' => $guids]) + ->all(); + $labelsByGuid = []; + foreach ($assortments as $a) { + if ($a->label) { + $labelsByGuid[$a->product_guid][] = $a->label; + } + } + foreach ($rows as &$r) { + $r['labels'] = $labelsByGuid[$r['product']->id] ?? []; + } + unset($r); + + // Концепции из 1С (ERP-330): graceful fallback если таблица не создана + $conceptsByGuid = []; + if (Yii::$app->db->getTableSchema(ProductConcept::tableName()) !== null) { + $concepts = ProductConcept::find() + ->where(['product_guid' => $guids]) + ->asArray() + ->all(); + foreach ($concepts as $c) { + $conceptsByGuid[$c['product_guid']][] = $c['concept_name']; } - } else { - $rows[] = [ - 'product' => $product, - 'actuality' => null, - ]; } + foreach ($rows as &$r) { + $r['concepts'] = $conceptsByGuid[$r['product']->id] ?? []; + } + unset($r); + } + + // Sort by date_actuality: descending by latest date_to + if ($sortBy === 'date_actuality') { + usort($rows, static function (array $a, array $b) { + $aMax = empty($a['actualities']) ? '' : max(array_map(static fn($x) => $x->date_to, $a['actualities'])); + $bMax = empty($b['actualities']) ? '' : max(array_map(static fn($x) => $x->date_to, $b['actualities'])); + return strcmp($bMax, $aMax); + }); + } + + // Counter cards + $counters = ['total' => count($rows), 'active' => 0, 'no_labels' => 0, 'no_score' => null]; + foreach ($rows as $r) { + $hasActive = false; + foreach ($r['actualities'] as $act) { + if ($act->getStatus() === 'active') { $hasActive = true; break; } + } + if ($hasActive) { $counters['active']++; } + if (empty($r['labels'])) { $counters['no_labels']++; } + } + if (!empty($guids) && Yii::$app->db->getTableSchema('erp24.product_mappings') !== null) { + $guidsWithScore = (new Query()) + ->select('pm.product_guid') + ->from(['pm' => 'erp24.product_mappings']) + ->innerJoin(['ps' => 'product_scores'], 'ps.mapping_id = pm.id') + ->where(['pm.product_guid' => $guids]) + ->distinct() + ->column(); + $counters['no_score'] = count($rows) - count(array_intersect($guids, $guidsWithScore)); } $dataProvider = new \yii\data\ArrayDataProvider([ 'allModels' => $rows, - 'pagination' => ['pageSize' => 1000], + 'pagination' => ['pageSize' => $pageSize], 'sort' => [ - 'attributes' => [ - 'product.name', - 'actuality.date_from', - 'actuality.date_to' - ], + 'attributes' => ['product.name'], 'defaultOrder' => ['product.name' => SORT_ASC], ], ]); @@ -264,18 +330,140 @@ class Products1cNomenclatureActualityController extends Controller } return $this->render('index', [ - 'filter' => $filter, + 'filter' => $filter, 'dataProvider' => $dataProvider, - 'categories' => array_combine($categories, $categories), + 'categories' => array_combine($categories, $categories), 'subcategories' => array_combine($subcategories, $subcategories), - 'species' => array_combine($species, $species), - 'types' => array_combine($lists['type'], $lists['type']), - 'colors' => array_combine($lists['color'], $lists['color']), - 'sorts' => array_combine($lists['sort'], $lists['sort']), - 'sizes' => array_combine($lists['size'], $lists['size']), + 'species' => array_combine($species, $species), + 'types' => array_combine($lists['type'], $lists['type']), + 'colors' => array_combine($lists['color'], $lists['color']), + 'sorts' => array_combine($lists['sort'], $lists['sort']), + 'sizes' => array_combine($lists['size'], $lists['size']), + 'counters' => $counters, + 'pageSize' => $pageSize, + 'sortBy' => $sortBy, ]); } + public function actionExportXlsx() + { + $filter = new \yii\base\DynamicModel([ + 'category', 'subcategory', 'species', + 'type', 'color', 'sort', 'size', + 'date_from', 'date_to', 'search', + ]); + $filter->addRule([ + 'category', 'subcategory', 'species', + 'type', 'color', 'sort', 'size', + 'date_from', 'date_to', 'search', + ], 'safe'); + $filter->addRule(['onlyActive', 'onlyInactive'], 'boolean'); + $filter->load(Yii::$app->request->get()); + + $attrMap = [ + 'type' => ['type', 'тип'], + 'color' => ['цвет', 'color'], + 'sort' => ['sort', 'сорт'], + 'size' => ['size', 'размер'], + ]; + + $query = Products1cNomenclature::find()->alias('n'); + + foreach (['category', 'subcategory', 'species'] as $attr) { + if ($filter->$attr) { $query->andWhere(["n.$attr" => $filter->$attr]); } + } + + foreach (['type', 'color', 'sort', 'size'] as $attr) { + if ($filter->$attr) { + $propIds = Products1cPropType::find()->select('id')->andWhere(['name' => $attrMap[$attr]])->column(); + $ids = Products1cAdditionalCharacteristics::find()->select('product_id')->distinct() + ->where(['property_id' => $propIds, 'value' => $filter->$attr])->column(); + if (empty($ids)) { $query->andWhere('0=1'); break; } + $query->andWhere(['n.id' => $ids]); + } + } + + if ($filter->search) { + $query->andWhere(['ilike', 'n.name', $filter->search]); + } + + if (!empty($filter->onlyActive)) { + $query->andWhere(['exists', + Products1cNomenclatureActuality::find()->where('guid = n.id')->select(new \yii\db\Expression('1')) + ]); + } elseif (!empty($filter->onlyInactive)) { + $query->andWhere(['not exists', + Products1cNomenclatureActuality::find()->where('guid = n.id')->select(new \yii\db\Expression('1')) + ]); + } + + if ($filter->date_from || $filter->date_to) { + $hasDateFrom = !empty($filter->date_from); + $hasDateTo = !empty($filter->date_to); + $dateFrom = $hasDateFrom ? (new \DateTime("{$filter->date_from}-01"))->setTime(0,0,0)->format('Y-m-d H:i:s') : null; + $dateTo = $hasDateTo ? (new \DateTime("{$filter->date_to}-01"))->modify('last day of this month')->setTime(23,59,59)->format('Y-m-d H:i:s') : null; + $dateExists = Products1cNomenclatureActuality::find()->alias('a')->where('a.guid = n.id'); + if ($hasDateFrom && !$hasDateTo) { $dateExists->andWhere(['>=', 'a.date_to', $dateFrom]); } + elseif (!$hasDateFrom && $hasDateTo) { $dateExists->andWhere(['<=', 'a.date_from', $dateTo]); } + else { $dateExists->andWhere(['>=', 'a.date_to', $dateFrom])->andWhere(['<=', 'a.date_from', $dateTo]); } + $cond = !empty($filter->onlyInactive) ? 'not exists' : 'exists'; + $query->andWhere([$cond, $dateExists->select(new \yii\db\Expression('1'))]); + } + + $query->with(['actualities' => fn($q) => $q->orderBy(['date_from' => SORT_ASC])]); + $products = $query->orderBy(['n.name' => SORT_ASC])->all(); + + if (!empty($products)) { + $guids = array_column(array_map(static fn($p) => ['id' => $p->id], $products), 'id'); + $assortments = ProductAssortment::find()->with('label')->where(['product_guid' => $guids])->all(); + $labelsByGuid = []; + foreach ($assortments as $a) { + if ($a->label) { $labelsByGuid[$a->product_guid][] = $a->label->name; } + } + } + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Актуальность'); + + $headers = ['Наименование', 'GUID', 'Интервалы актуальности', 'Лейблы', 'Статус']; + foreach ($headers as $col => $title) { + $sheet->setCellValueByColumnAndRow($col + 1, 1, $title); + $sheet->getColumnDimensionByColumn($col + 1)->setAutoSize(true); + } + $sheet->getStyle('A1:E1')->getFont()->setBold(true); + + $rowNum = 2; + foreach ($products as $product) { + $acts = $product->actualities ?? []; + $intervalsText = implode('; ', array_map( + static fn($a) => substr($a->date_from, 0, 7) . ' – ' . substr($a->date_to, 0, 7), + $acts + )); + $hasActive = false; + foreach ($acts as $act) { + if ($act->getStatus() === 'active') { $hasActive = true; break; } + } + $labelsText = implode(', ', $labelsByGuid[$product->id] ?? []); + + $sheet->setCellValueByColumnAndRow(1, $rowNum, $product->name); + $sheet->setCellValueByColumnAndRow(2, $rowNum, $product->id); + $sheet->setCellValueByColumnAndRow(3, $rowNum, $intervalsText); + $sheet->setCellValueByColumnAndRow(4, $rowNum, $labelsText); + $sheet->setCellValueByColumnAndRow(5, $rowNum, $hasActive ? 'Активен' : 'Не активен'); + $rowNum++; + } + + $filename = 'actuality_' . date('Y-m-d') . '.xlsx'; + header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Cache-Control: max-age=0'); + + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + $writer->save('php://output'); + Yii::$app->end(); + } + public function actionAddActivity() { $request = Yii::$app->request; @@ -556,6 +744,424 @@ class Products1cNomenclatureActualityController extends Controller } } + public function actionAjaxIntervals(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->get('guid'); + if (!$guid) { + throw new BadRequestHttpException('Missing guid'); + } + + $intervals = Products1cNomenclatureActuality::find() + ->where(['guid' => $guid]) + ->orderBy(['date_from' => SORT_ASC]) + ->all(); + + return [ + 'success' => true, + 'intervals' => array_map(static fn($i) => [ + 'id' => $i->id, + 'date_from' => $i->date_from, + 'date_to' => $i->date_to, + 'from_ym' => substr($i->date_from, 0, 7), + 'to_ym' => $i->date_to ? substr($i->date_to, 0, 7) : null, + 'status' => $i->getStatus(), + 'label' => $i->getLabel(), + ], $intervals), + ]; + } + + public function actionAjaxSaveInterval(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $post = Yii::$app->request->post(); + $guid = $post['guid'] ?? null; + $id = !empty($post['id']) ? (int)$post['id'] : null; + $fromYm = $post['date_from'] ?? null; + $toYm = $post['date_to'] ?? null; + + if (!$guid || !$fromYm || !$toYm) { + return ['success' => false, 'message' => 'Заполните все поля']; + } + + $from = (new \DateTime("{$fromYm}-01"))->setTime(0, 0, 0)->format('Y-m-d H:i:s'); + $to = (new \DateTime("{$toYm}-01"))->modify('last day of this month')->setTime(23, 59, 59)->format('Y-m-d H:i:s'); + + if ($from > $to) { + return ['success' => false, 'message' => 'Дата начала не может быть позже даты окончания']; + } + + if (!$id) { + $count = Products1cNomenclatureActuality::find()->where(['guid' => $guid])->count(); + if ($count >= 10) { + return ['success' => false, 'message' => 'Максимум 10 интервалов на товар']; + } + } + + $overlapQuery = Products1cNomenclatureActuality::find() + ->where(['guid' => $guid]) + ->andWhere('date_to >= :from', [':from' => $from]) + ->andWhere('date_from <= :to', [':to' => $to]); + if ($id) { + $overlapQuery->andWhere(['<>', 'id', $id]); + } + if ($overlapQuery->exists()) { + return ['success' => false, 'message' => 'Интервал пересекается с существующим']; + } + + $userId = Yii::$app->user->id; + $now = date('Y-m-d H:i:s'); + + if ($id) { + $model = $this->findModel($id); + $model->updated_at = $now; + $model->updated_by = $userId; + } else { + $model = new Products1cNomenclatureActuality([ + 'guid' => $guid, + 'created_at' => $now, + 'created_by' => $userId, + ]); + } + $model->date_from = $from; + $model->date_to = $to; + + if (!$model->save()) { + return ['success' => false, 'message' => implode(', ', $model->getFirstErrors())]; + } + + return ['success' => true, 'message' => 'Сохранено']; + } + + public function actionAjaxLabels(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->get('guid'); + if (!$guid) { + throw new BadRequestHttpException('Missing guid'); + } + + $assigned = ProductAssortment::find() + ->select('label_id') + ->where(['product_guid' => $guid]) + ->column(); + + $labels = AssortmentLabel::findActive(); + + return [ + 'success' => true, + 'labels' => array_map(static fn($l) => [ + 'id' => $l->id, + 'name' => $l->name, + 'channel_type' => $l->channel_type, + 'color' => $l->color, + 'is_assigned' => in_array($l->id, $assigned, true), + ], $labels), + ]; + } + + public function actionAjaxSaveAssortment(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $post = Yii::$app->request->post(); + $guid = $post['guid'] ?? null; + $labelIds = array_map('intval', (array)($post['label_ids'] ?? [])); + + if (!$guid) { + return ['success' => false, 'message' => 'Missing guid']; + } + + $current = ProductAssortment::find() + ->where(['product_guid' => $guid]) + ->all(); + + $currentIds = array_map(static fn($r) => $r->label_id, $current); + + $toDelete = array_diff($currentIds, $labelIds); + $toInsert = array_diff($labelIds, $currentIds); + + $transaction = Yii::$app->db->beginTransaction(); + try { + foreach ($current as $row) { + if (in_array($row->label_id, $toDelete, true)) { + $row->delete(); + } + } + + foreach ($toInsert as $lid) { + $row = new ProductAssortment([ + 'product_guid' => $guid, + 'label_id' => $lid, + ]); + if (!$row->save()) { + $transaction->rollBack(); + return ['success' => false, 'message' => implode(', ', $row->getFirstErrors())]; + } + } + + $transaction->commit(); + } catch (\Throwable $e) { + $transaction->rollBack(); + return ['success' => false, 'message' => $e->getMessage()]; + } + + $assigned = ProductAssortment::find() + ->with('label') + ->where(['product_guid' => $guid]) + ->all(); + + return [ + 'success' => true, + 'labels' => array_map(static fn($r) => [ + 'id' => $r->label->id, + 'name' => $r->label->name, + 'color' => $r->label->color, + 'channel_type' => $r->label->channel_type, + ], $assigned), + ]; + } + + public function actionAjaxRemoveLabel(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $post = Yii::$app->request->post(); + $guid = $post['guid'] ?? null; + $labelId = !empty($post['label_id']) ? (int)$post['label_id'] : null; + + if (!$guid || !$labelId) { + return ['success' => false, 'message' => 'Missing guid or label_id']; + } + + $row = ProductAssortment::findOne(['product_guid' => $guid, 'label_id' => $labelId]); + if (!$row) { + return ['success' => false, 'message' => 'Not found']; + } + + $row->delete(); + + return ['success' => true]; + } + + public function actionAjaxBulkAssign(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $post = Yii::$app->request->post(); + $guids = array_values(array_filter((array)($post['guids'] ?? []))); + $labelIds = array_values(array_filter(array_map('intval', (array)($post['label_ids'] ?? [])))); + $action = $post['action'] ?? 'add'; + + if (empty($guids) || empty($labelIds)) { + return ['success' => false, 'message' => 'Не выбраны товары или лейблы']; + } + + if (!in_array($action, ['add', 'remove'], true)) { + return ['success' => false, 'message' => 'Неизвестное действие']; + } + + $transaction = Yii::$app->db->beginTransaction(); + try { + if ($action === 'add') { + foreach ($guids as $guid) { + $existingIds = ProductAssortment::find() + ->select('label_id') + ->where(['product_guid' => $guid, 'label_id' => $labelIds]) + ->column(); + + foreach (array_diff($labelIds, $existingIds) as $labelId) { + $row = new ProductAssortment([ + 'product_guid' => $guid, + 'label_id' => (int)$labelId, + ]); + if (!$row->save()) { + $transaction->rollBack(); + return ['success' => false, 'message' => implode(', ', $row->getFirstErrors())]; + } + } + } + } else { + ProductAssortment::deleteAll(['product_guid' => $guids, 'label_id' => $labelIds]); + } + + $transaction->commit(); + } catch (\Throwable $e) { + $transaction->rollBack(); + return ['success' => false, 'message' => $e->getMessage()]; + } + + $allAssortments = ProductAssortment::find() + ->with('label') + ->where(['product_guid' => $guids]) + ->all(); + + $resultsByGuid = array_fill_keys($guids, []); + foreach ($allAssortments as $a) { + if ($a->label) { + $resultsByGuid[$a->product_guid][] = [ + 'id' => $a->label->id, + 'name' => $a->label->name, + 'color' => $a->label->color, + ]; + } + } + + return [ + 'success' => true, + 'message' => $action === 'add' ? 'Лейблы добавлены' : 'Лейблы убраны', + 'updated_count' => count($guids), + 'results' => $resultsByGuid, + ]; + } + + public function actionAjaxScores(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->get('guid'); + if (!$guid) { + throw new BadRequestHttpException('Missing guid'); + } + + $mappingsTableExists = Yii::$app->db->getTableSchema('erp24.product_mappings') !== null; + + if (!$mappingsTableExists) { + return ['success' => true, 'mappings' => [], 'avg_store_score' => null, 'stub_mode' => true]; + } + + $mappings = (new Query()) + ->select([ + 'pm.id', + 'pm.supplier_product_name', + 'pm.deleted_at', + 's.name AS supplier_name', + 'pl.name AS plantation_name', + ]) + ->from(['pm' => 'erp24.product_mappings']) + ->leftJoin(['s' => 'erp24.suppliers'], 's.id = pm.supplier_id') + ->leftJoin(['pl' => 'erp24.plantations'], 'pl.id = pm.plantation_id') + ->where(['pm.product_guid' => $guid]) + ->orderBy(['pm.deleted_at' => SORT_ASC, 'pm.id' => SORT_ASC]) + ->all(); + + $mappingIds = array_column($mappings, 'id'); + + $scores = $mappingIds + ? ProductScore::find()->where(['mapping_id' => $mappingIds])->indexBy('mapping_id')->all() + : []; + + $avgStoreScore = StoreProductRating::avgForProduct($guid); + + return [ + 'success' => true, + 'avg_store_score' => $avgStoreScore, + 'mappings' => array_map(static function (array $m) use ($scores) { + /** @var ProductScore|null $score */ + $score = $scores[$m['id']] ?? null; + return [ + 'id' => $m['id'], + 'supplier_name' => $m['supplier_name'] ?? '—', + 'plantation_name' => $m['plantation_name'], + 'supplier_product_name' => $m['supplier_product_name'], + 'deleted_at' => $m['deleted_at'], + 'score' => $score ? $score->score : null, + 'comment' => $score ? $score->comment : null, + 'km_comment_at' => $score ? $score->km_comment_at : null, + ]; + }, $mappings), + ]; + } + + public function actionAjaxSaveScore(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $post = Yii::$app->request->post(); + $mappingId = !empty($post['mapping_id']) ? (int)$post['mapping_id'] : null; + $score = isset($post['score']) && $post['score'] !== '' ? (int)$post['score'] : null; + $comment = isset($post['comment']) ? trim($post['comment']) : null; + + if (!$mappingId) { + return ['success' => false, 'message' => 'Missing mapping_id']; + } + + if ($score !== null && ($score < 1 || $score > 5)) { + return ['success' => false, 'message' => 'Оценка должна быть от 1 до 5']; + } + + $userId = Yii::$app->user->id; + $now = date('Y-m-d H:i:s'); + + $model = ProductScore::findByMapping($mappingId); + if (!$model) { + $model = new ProductScore([ + 'mapping_id' => $mappingId, + 'km_user_id' => $userId, + ]); + } + + $model->score = $score; + $model->comment = $comment ?: null; + $model->km_user_id = $userId; + $model->km_comment_at = ($comment !== null) ? $now : $model->km_comment_at; + + if (!$model->save()) { + return ['success' => false, 'message' => implode(', ', $model->getFirstErrors())]; + } + + return ['success' => true, 'message' => 'Сохранено']; + } + + public function actionAjaxAddTestMapping(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + if (!YII_DEBUG) { + return ['success' => false, 'message' => 'Доступно только в режиме разработки']; + } + + $guid = Yii::$app->request->post('guid'); + $productName = trim(Yii::$app->request->post('product_name', 'Тестовый товар')); + + if (!$guid) { + return ['success' => false, 'message' => 'Missing guid']; + } + + $db = Yii::$app->db; + + if ($db->getTableSchema('erp24.product_mappings') === null) { + return ['success' => false, 'message' => 'Таблица product_mappings не найдена. Запустите миграции ERP-300.']; + } + + // Используем первого поставщика или создаём тестового + $supplierId = (new Query())->select('id')->from('erp24.suppliers')->scalar(); + if (!$supplierId) { + return ['success' => false, 'message' => 'Нет поставщиков в справочнике. Добавьте через /supplier.']; + } + + $userId = Yii::$app->user->id; + $now = date('Y-m-d H:i:s'); + + try { + $db->createCommand()->insert('erp24.product_mappings', [ + 'product_guid' => $guid, + 'supplier_id' => $supplierId, + 'supplier_product_name' => $productName, + 'quant' => 1, + 'created_by' => $userId, + 'created_at' => $now, + ])->execute(); + } catch (\Throwable $e) { + return ['success' => false, 'message' => $e->getMessage()]; + } + + return ['success' => true, 'message' => 'Тестовый маппинг добавлен']; + } + /** * Displays a single Products1cNomenclatureActuality model. * @param int $id ID diff --git a/erp24/controllers/Products1cNomenclatureMarkupController.php b/erp24/controllers/Products1cNomenclatureMarkupController.php new file mode 100644 index 00000000..1e832906 --- /dev/null +++ b/erp24/controllers/Products1cNomenclatureMarkupController.php @@ -0,0 +1,342 @@ + 'Не нужна', + self::STATUS_UNCLASSIFIED => 'Не размечен', + self::STATUS_PENDING => 'Подтвердить', + self::STATUS_APPROVED => 'Подтверждён', + ]; + } + + public function behaviors(): array + { + return array_merge(parent::behaviors(), [ + 'access' => [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'allow' => true, + 'actions' => ['ajax-approve', 'ajax-bulk-approve', 'ajax-mark-not-needed', 'ajax-save-markup', 'ajax-revert-markup'], + 'permissions' => ['catmanager/manage'], + ], + [ + 'allow' => true, + 'actions' => ['index', 'ajax-product-data'], + 'permissions' => ['catmanager/view'], + ], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'ajax-approve' => ['POST'], + 'ajax-bulk-approve' => ['POST'], + 'ajax-mark-not-needed' => ['POST'], + 'ajax-save-markup' => ['POST'], + 'ajax-revert-markup' => ['POST'], + ], + ], + ]); + } + + public function actionIndex(): string + { + $filter = new DynamicModel([ + 'category', 'species', 'classification_status', 'confidence_range', 'pageSize', + ]); + $filter->addRule(['category', 'species', 'classification_status', 'confidence_range'], 'safe'); + $filter->addRule(['pageSize'], 'integer'); + $filter->load(Yii::$app->request->get()); + + $pageSize = in_array((int)$filter->pageSize, [50, 100, 500]) ? (int)$filter->pageSize : 50; + + // Base query with category/species filters (used for counter cards too) + $baseQuery = Products1cNomenclature::find(); + if ($filter->category) { + $baseQuery->andWhere(['category' => $filter->category]); + } + if ($filter->species) { + $baseQuery->andWhere(['species' => $filter->species]); + } + + // Counter cards (no status/confidence filter — show all statuses) + $counters = array_fill_keys([ + self::STATUS_NOT_NEEDED, self::STATUS_UNCLASSIFIED, + self::STATUS_PENDING, self::STATUS_APPROVED, + ], 0); + $counts = (clone $baseQuery) + ->select(['classification_status', 'cnt' => 'COUNT(*)']) + ->groupBy('classification_status') + ->asArray() + ->all(); + foreach ($counts as $row) { + $key = $row['classification_status'] ?? self::STATUS_UNCLASSIFIED; + if (array_key_exists($key, $counters)) { + $counters[$key] = (int)$row['cnt']; + } + } + + // Full query with all filters + $query = clone $baseQuery; + if ($filter->classification_status) { + $query->andWhere(['classification_status' => $filter->classification_status]); + } + if ($filter->confidence_range) { + match ($filter->confidence_range) { + 'high' => $query->andWhere(['>=', 'confidence', 90]), + 'medium' => $query->andWhere(['between', 'confidence', 70, 89]), + 'low' => $query->andWhere(['<', 'confidence', 70]), + default => null, + }; + } + $query->orderBy(['name' => SORT_ASC]); + + $dataProvider = new ActiveDataProvider([ + 'query' => $query, + 'pagination' => ['pageSize' => $pageSize], + ]); + + $categories = array_filter( + Products1cNomenclature::find()->select('category')->distinct()->orderBy('category')->column() + ); + $speciesList = array_filter( + Products1cNomenclature::find()->select('species')->distinct() + ->andWhere(['not', ['species' => null]])->orderBy('species')->column() + ); + $sorts = array_filter( + Products1cNomenclature::find()->select('sort')->distinct() + ->andWhere(['not', ['sort' => null]])->orderBy('sort')->column() + ); + $colors = array_filter( + Products1cNomenclature::find()->select('color')->distinct() + ->andWhere(['not', ['color' => null]])->orderBy('color')->column() + ); + + return $this->render('index', [ + 'filter' => $filter, + 'dataProvider' => $dataProvider, + 'counters' => $counters, + 'categories' => array_combine($categories, $categories), + 'speciesList' => array_combine($speciesList, $speciesList), + 'sorts' => array_values($sorts), + 'colors' => array_values($colors), + 'pageSize' => $pageSize, + ]); + } + + public function actionAjaxApprove(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->post('guid'); + if (!$guid) { + return ['success' => false, 'message' => 'Missing guid']; + } + + $product = $this->findProduct($guid); + + if ($product->classification_status === self::STATUS_UNCLASSIFIED) { + return ['success' => false, 'message' => 'Неразмеченный товар нельзя подтвердить напрямую. Используйте ✎ редактирование.']; + } + + return $this->applyStatus($product, self::STATUS_APPROVED); + } + + public function actionAjaxBulkApprove(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guids = array_values(array_filter((array)Yii::$app->request->post('guids', []))); + if (empty($guids)) { + return ['success' => false, 'message' => 'Не выбраны товары']; + } + + $now = date('Y-m-d H:i:s'); + $userId = (int)Yii::$app->user->id; + + $updated = Products1cNomenclature::updateAll( + [ + 'classification_status' => self::STATUS_APPROVED, + 'classified_by' => $userId, + 'classified_at' => $now, + ], + ['id' => $guids, 'classification_status' => self::STATUS_PENDING] + ); + + return [ + 'success' => true, + 'message' => "Подтверждено: {$updated} из " . count($guids) . ' (только pending)', + 'updated' => $updated, + ]; + } + + public function actionAjaxMarkNotNeeded(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->post('guid'); + if (!$guid) { + return ['success' => false, 'message' => 'Missing guid']; + } + + return $this->applyStatus($this->findProduct($guid), self::STATUS_NOT_NEEDED); + } + + public function actionAjaxRevertMarkup(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->post('guid'); + $reason = trim(Yii::$app->request->post('reason', '')); + + if (!$guid) { + return ['success' => false, 'message' => 'Missing guid']; + } + if ($reason === '') { + return ['success' => false, 'message' => 'Причина обязательна']; + } + + $product = $this->findProduct($guid); + + if ($product->classification_status !== self::STATUS_NOT_NEEDED) { + return ['success' => false, 'message' => 'Статус товара не not_needed']; + } + + AuditLog::write( + Products1cNomenclature::tableName(), + $product->id, + AuditLog::ACTION_UPDATE, + ['classification_status' => self::STATUS_NOT_NEEDED], + ['classification_status' => self::STATUS_UNCLASSIFIED, 'reason' => $reason], + ); + + $product->classification_status = self::STATUS_UNCLASSIFIED; + $product->classified_by = (int)Yii::$app->user->id; + $product->classified_at = date('Y-m-d H:i:s'); + $product->confidence = null; + + if (!$product->save(false)) { + return ['success' => false, 'message' => 'Ошибка сохранения']; + } + + return ['success' => true, 'status' => self::STATUS_UNCLASSIFIED]; + } + + public function actionAjaxProductData(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $guid = Yii::$app->request->get('guid'); + if (!$guid) { + throw new BadRequestHttpException('Missing guid'); + } + + $p = $this->findProduct($guid); + + return [ + 'success' => true, + 'guid' => $p->id, + 'name' => $p->name, + 'category' => $p->category, + 'species' => $p->species, + 'sort' => $p->sort, + 'color' => $p->color, + 'size' => $p->size, + 'classification_status' => $p->classification_status, + 'confidence' => $p->confidence, + ]; + } + + public function actionAjaxSaveMarkup(): array + { + Yii::$app->response->format = Response::FORMAT_JSON; + + $post = Yii::$app->request->post(); + $guid = $post['guid'] ?? null; + if (!$guid) { + return ['success' => false, 'message' => 'Missing guid']; + } + + $product = $this->findProduct($guid); + + $category = trim($post['category'] ?? ''); + if (!$category) { + return ['success' => false, 'message' => 'Категория обязательна']; + } + + $product->category = $category; + $product->species = trim($post['species'] ?? '') ?: null; + $product->sort = trim($post['sort'] ?? '') ?: null; + $product->color = trim($post['color'] ?? '') ?: null; + $product->size = isset($post['size']) && $post['size'] !== '' ? (int)$post['size'] : null; + $product->classification_status = self::STATUS_APPROVED; + $product->classified_by = (int)Yii::$app->user->id; + $product->classified_at = date('Y-m-d H:i:s'); + $product->confidence = null; + + if (!$product->save(false)) { + return ['success' => false, 'message' => 'Ошибка сохранения']; + } + + return [ + 'success' => true, + 'message' => 'Сохранено и подтверждено', + 'product' => [ + 'guid' => $product->id, + 'category' => $product->category, + 'species' => $product->species, + 'sort' => $product->sort, + 'color' => $product->color, + 'size' => $product->size, + 'classification_status' => $product->classification_status, + ], + ]; + } + + private function applyStatus(Products1cNomenclature $product, string $status): array + { + $product->classification_status = $status; + $product->classified_by = (int)Yii::$app->user->id; + $product->classified_at = date('Y-m-d H:i:s'); + + if (!$product->save(false)) { + return ['success' => false, 'message' => 'Ошибка сохранения']; + } + + return ['success' => true, 'status' => $status]; + } + + private function findProduct(string $guid): Products1cNomenclature + { + $model = Products1cNomenclature::findOne($guid); + if ($model === null) { + throw new NotFoundHttpException('Товар не найден'); + } + return $model; + } +} diff --git a/erp24/docs/erp325_catmanager_presentation.html b/erp24/docs/erp325_catmanager_presentation.html new file mode 100644 index 00000000..1f2246f7 --- /dev/null +++ b/erp24/docs/erp325_catmanager_presentation.html @@ -0,0 +1,677 @@ + + + + + +ERP-325 — Каталог категорийного менеджера + + + + + + + +
+
+
+
+
+ ERP-325 + feature_fomichev_ERP-325J + 2026-04-22 +
+

Каталог категорийного менеджера

+

Полная реализация модуля CM: разметка номенклатуры, bulk-операции, RBAC, концепции из 1С.

+
+ ERP-332 ✓ + ERP-333 ✓ + ERP-337 ✓ + ERP-334 ✓ + ERP-330 ✓ +
+
+
+
+
Статистика
+
+
5
задач
+
3
миграции
+
12
файлов
+
+
+
+
+
+
+ + + + +
+ + +
+
+
+
Вкладка: Актуальность
+
+

Существующая страница — расширена bulk-операциями с лейблами и отображением концепций из 1С.

+
    +
  • Products1cNomenclatureActualityController
  • +
  • views/products1c-nomenclature-actuality/index.php
  • +
  • web/js/…Actuality/index.js
  • +
+
+
+
+
+
+
Вкладка: Разметка
+
+

Новая страница — workflow классификации товаров с 4 статусами, фильтрами, bulk и edit-модалкой.

+
    +
  • Products1cNomenclatureMarkupController
  • +
  • views/products1c-nomenclature-markup/index.php
  • +
  • web/js/…Markup/index.js
  • +
+
+
+
+
+
+
Вкладка: Матрица
+
+

Существующая страница лейблов — добавлен AccessControl.

+
    +
  • AssortmentLabelController
  • +
+
+
+
+
+ + +

Архитектура модуля

+
+
+graph TB + subgraph Browser["🌐 Браузер"] + TabA["Вкладка: Актуальность"] + TabB["Вкладка: Разметка"] + TabC["Вкладка: Матрица"] + end + + subgraph Controllers["⚙️ Контроллеры (app\\controllers)"] + CA["Products1cNomenclatureActualityController
actionIndex / ajaxIntervals / ajaxLabels
ajaxScores / ajaxBulkAssign / ..."] + CM["Products1cNomenclatureMarkupController
actionIndex / ajaxApprove / ajaxBulkApprove
ajaxMarkNotNeeded / ajaxSaveMarkup
ajaxRevertMarkup"] + CL["AssortmentLabelController
actionIndex / ajaxList
ajaxSave / ajaxToggleActive / ajaxDelete"] + end + + subgraph Records["📦 AR-Модели (yii_app\\records)"] + N["Products1cNomenclature
products_1c_nomenclature"] + PA["ProductAssortment
product_assortment"] + AL["AssortmentLabel
assortment_labels"] + PC["ProductConcept
product_concepts"] + AUD["AuditLog
audit_log"] + end + + subgraph Infra["🔧 Инфраструктура"] + RBAC["Yii2 RBAC
DbManager
catmanager/view
catmanager/manage"] + CRON["Cron
products1c-concept/sync"] + SOURCE["bouquet_components
(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 +
+
+ + +

ERP-333 CM-2.1 — Вкладка «Разметка»

+
+
+
Статусная машина классификации
+
+
+stateDiagram-v2 + [*] --> unclassified : новый товар из 1С + unclassified --> pending : ML-классификация + unclassified --> not_needed : КМ нажимает ⊘ + pending --> approved : КМ нажимает ✓
или ✎ Save + pending --> not_needed : КМ нажимает ⊘ + pending --> approved : Bulk-подтверждение + not_needed --> unclassified : КМ нажимает ↩
(ERP-334, с причиной) + approved --> approved : КМ редактирует ✎ + + note right of not_needed + Audit log записывается + при реверсе (ERP-334) + end note +
+
+
+
+
Раскраска строк по статусу / confidence
+ + + + + + + + + + +
СтатусConfidenceФон строкиКнопки
Не нужна—#f8f9fa, opacity .75✎ ↩
Не размечен—#fff5f5✎ ⊘
Подтвердить≥ 90%белый✓ ✎ ⊘
Подтвердить70–89%#fffbeb✓ ✎ ⊘
Подтвердить< 70%#fff5f5✓ ✎ ⊘
Подтверждён—белый✎
+
Действия (sequence)
+
+
+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 «Подтверждено» +
+
+
+
+ + +
+
Edit-модалка (Variant B — полное редактирование)
+
+
+
+

Открывается по ✎, поля заполняются из data-* атрибутов кнопки (без доп. AJAX-запроса). Списки категорий/видов/сортов/цветов предзагружены в window.markupConfig.

+
    +
  • Категория — обязательная, select
  • +
  • Вид — опционально, select
  • +
  • Сорт — опционально, select
  • +
  • Цвет — опционально, select
  • +
  • Высота (см) — число, input[type=number]
  • +
+

При сохранении: статус → approved, confidence=null, classified_by/at — текущий пользователь.

+
+
+
+
+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 «Сохранено и подтверждено» +
+
+
+
+
+
+ + +

ERP-332 CM-1.6 — Bulk-операции с лейблами

+
+
+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 «Добавлено» +
+
+ + +

ERP-337 CM-4.2 — RBAC

+
+
+
+
+graph LR + subgraph Roles["Роли (group_id)"] + KM["КМ
group_id=82
GROUP_CATMANAGER"] + BUY["Закупщик
group_id=?
(настраивается)"] + DIR["Руководитель
group_id=?
(настраивается)"] + end + + subgraph Perms["Разрешения"] + MNG["catmanager/manage
Полный CRUD"] + VIEW["catmanager/view
Read-only"] + end + + subgraph Actions["Действия контроллеров"] + WRITE["✓ approve
⊘ not-needed
✎ save-markup
↩ revert
bulk-approve
bulk-assign
ajax-save/delete"] + READ["index
ajax-list
ajax-product-data
ajax-intervals
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 +
+
+
+
+
Матрица доступа
+ + + + + + + + + + + + + +
ДействиеКМЗакупщикРуководитель
Просмотр страниц✓✓✓
Approve markup✓✗✗
Edit markup✓✗✗
Not needed / Revert✓✗✗
Bulk approve✓✗✗
Bulk label assign✓✗✗
CRUD лейблов✓✗✗
+
+ После деплоя: запустить php yii auth/init. Закупщик/Руководитель — настроить через /auth/show-groups. +
+
+
+ + +

ERP-334 CM-2.2 — Реверс not_needed

+
+
+
+
+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 +
+
+
+
+
Запись в audit_log
+ + + + + + + + +
entity_typeproducts_1c_nomenclature
entity_idGUID товара
actionupdate
old_values{"classification_status":"not_needed"}
new_values{"classification_status":"unclassified","reason":"..."}
user_idID текущего КМ
user_roleИмя группы из admin_group
+
+
Доступность кнопок по статусу
+ + + + + + + + +
Статус✓✎⊘↩
not_needed—✓—✓
unclassified—✓✓—
pending✓✓✓—
approved—✓——
+
+
+
+ + +

ERP-330 CM-1.4 — Концепции из 1С

+
+
+
+
+sequenceDiagram + participant CRON as Cron (ежедневно 03:00) + participant CMD as Products1cConceptController + participant SRC as bouquet_components
(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 +
+
+
+
+graph LR + subgraph Actuality["Страница Актуальности"] + ROW["Строка товара"] + LABELS["Лейблы-чипы
(цветные, removable)"] + CONCEPTS["Концепции-чипы
(фиолетовые dashed, read-only)"] + end + ROW --> LABELS + ROW --> CONCEPTS + + subgraph Data["Источники данных"] + PA["product_assortment
+ assortment_labels"] + PC["product_concepts"] + end + + PA --> LABELS + PC --> CONCEPTS + + style CONCEPTS fill:#f3e8ff,stroke:#7c3aed + style PC fill:#f3e8ff,stroke:#7c3aed +
+
+
+
+
Внешний вид концепций
+
+
Пример: лейблы + концепции в строке
+
+ Топ-ассортимент + Розница +
+
+ 🎨 Классика + 🎨 Премиум + 🎨 Моно +
+
+
CSS концепций
+
.concept-chip {
+  background: #f3e8ff;
+  color: #7c3aed;
+  border: 1px dashed #d8b4fe;
+  font-size: 10px;
+  padding: 2px 8px;
+  border-radius: 4px;
+}
+
+ Graceful fallback: если таблица product_concepts не создана (до первой миграции) — контроллер проверяет её существование через getTableSchema() и не падает с ошибкой. +
+
+
+ + +

Схема данных

+
+
+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" +
+
+ + +

Порядок деплоя

+
+
# 1. Применить миграции
+php yii migrate
+# Применяет:
+#   m260422_100000_add_classification_fields_to_nomenclature
+#   m260422_110000_add_catmanager_rbac
+#   m260422_120000_create_product_concepts_table
+
+# 2. Инициализировать RBAC (пересоздаёт permission-таблицы)
+php yii auth/init
+
+# 3. Добавить cron-задачу (раз в сутки, 03:00)
+crontab -e
+# Добавить строку:
+0 3 * * * php /var/www/erp24/yii products1c-concept/sync >> /var/log/concept-sync.log 2>&1
+
+# 4. Настроить RBAC для Закупщика и Руководителя
+# Через UI: /auth/show-groups — добавить 'catmanager/view' к нужным группам
+# После чего повторно запустить: php yii auth/init
+
+ + +

Полный список изменений

+
+
+
+
Новые файлы
+
+
    +
  • migrations/m260422_100000_add_classification_fields_to_nomenclature.php
  • +
  • migrations/m260422_110000_add_catmanager_rbac.php
  • +
  • migrations/m260422_120000_create_product_concepts_table.php
  • +
  • controllers/Products1cNomenclatureMarkupController.php
  • +
  • records/ProductConcept.php
  • +
  • commands/Products1cConceptController.php
  • +
  • views/products1c-nomenclature-markup/index.php
  • +
  • web/js/products1cNomenclatureMarkup/index.js
  • +
+
+
+
+
+
+
Изменённые файлы
+
+
    +
  • records/Products1cNomenclature.php — rules + @property
  • +
  • records/AdminGroup.php — GROUP_CATMANAGER const
  • +
  • controllers/Products1cNomenclatureActualityController.php — AccessControl, concepts loading
  • +
  • controllers/AssortmentLabelController.php — AccessControl
  • +
  • views/products1c-nomenclature-actuality/index.php — tabs, concepts, bulk toolbar
  • +
  • web/js/products1cNomenclatureActuality/index.js — bulk section
  • +
+
+
+
+
+ +
+ +
+
+ ERP-325 Каталог категорийного менеджера · Реализация: 2026-04-22 · feature_fomichev_ERP-325J_category_manager_catalog +
+
+ + + + + diff --git a/erp24/migrations/m241228_092653_add_target_date_column_to_sent_kogort_table.php b/erp24/migrations/m241228_092653_add_target_date_column_to_sent_kogort_table.php new file mode 100644 index 00000000..75f01ab7 --- /dev/null +++ b/erp24/migrations/m241228_092653_add_target_date_column_to_sent_kogort_table.php @@ -0,0 +1,40 @@ +db->schema->getTableSchema(self::TABLE_NAME) === null) { + return; + } + + if ($this->db->schema->getTableSchema(self::TABLE_NAME)->getColumn('target_date') === null) { + $this->addColumn(self::TABLE_NAME, + 'target_date', + $this->date()->notNull()->comment('Целевая дата')); + } + } + + /** + * {@inheritdoc} + */ + public function safeDown() + { + if ($this->db->schema->getTableSchema(self::TABLE_NAME) === null) { + return; + } + + if ($this->db->schema->getTableSchema(self::TABLE_NAME)->getColumn('target_date') !== null) { + $this->dropColumn(self::TABLE_NAME, 'target_date'); + } + } +} diff --git a/erp24/migrations/m260420_120000_create_audit_log_table.php b/erp24/migrations/m260420_120000_create_audit_log_table.php new file mode 100644 index 00000000..7beaf9be --- /dev/null +++ b/erp24/migrations/m260420_120000_create_audit_log_table.php @@ -0,0 +1,101 @@ +db->getTableSchema(self::TABLE)) { + return; + } + + // Родительская таблица с партиционированием по дате + $this->db->createCommand(<<execute(); + + // Кварталы 2026 + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + // Кварталы 2027 + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + // Индексы на родительской таблице — автоматически наследуются партициями в PG 11+ + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + + $this->db->createCommand(<<execute(); + } + + public function safeDown(): void + { + // Партиции удаляются каскадно вместе с родительской таблицей + $this->db->createCommand('DROP TABLE IF EXISTS audit_log CASCADE')->execute(); + } +} diff --git a/erp24/migrations/m260420_130000_create_assortment_tables.php b/erp24/migrations/m260420_130000_create_assortment_tables.php new file mode 100644 index 00000000..7307dc5b --- /dev/null +++ b/erp24/migrations/m260420_130000_create_assortment_tables.php @@ -0,0 +1,74 @@ +db->getTableSchema(self::LABELS_TABLE)) { + $this->createTable(self::LABELS_TABLE, [ + 'id' => $this->primaryKey(), + 'name' => $this->string(100)->notNull()->comment('Название лейбла'), + 'channel_type' => $this->string(20)->null() + ->comment('Тип канала: offline / online / marketplace / NULL'), + 'color' => $this->string(7)->null()->comment('HEX-цвет, например #4CAF50'), + 'icon' => $this->string(50)->null()->comment('CSS-класс иконки, например fa-star'), + 'is_active' => $this->boolean()->notNull()->defaultValue(true), + 'created_at' => $this->dateTime()->notNull(), + 'updated_at' => $this->dateTime()->null(), + 'created_by' => $this->integer()->notNull(), + 'updated_by' => $this->integer()->null(), + ]); + + $this->createIndex('idx_assortment_labels_active', self::LABELS_TABLE, 'is_active'); + } + + // ── N:M: товар ↔ лейбл ──────────────────────────────────────────── + if (!$this->db->getTableSchema(self::ASSORTMENT_TABLE)) { + $this->createTable(self::ASSORTMENT_TABLE, [ + 'id' => $this->primaryKey(), + 'product_guid' => $this->string(255)->notNull() + ->comment('FK → products_1c_nomenclature.id'), + 'label_id' => $this->integer()->notNull() + ->comment('FK → assortment_labels.id'), + 'created_at' => $this->dateTime()->notNull(), + 'created_by' => $this->integer()->notNull(), + ]); + + $this->createIndex( + 'uq_product_assortment', + self::ASSORTMENT_TABLE, + ['product_guid', 'label_id'], + true // unique + ); + + $this->createIndex( + 'idx_product_assortment_guid', + self::ASSORTMENT_TABLE, + 'product_guid' + ); + + $this->addForeignKey( + 'fk_product_assortment_label', + self::ASSORTMENT_TABLE, 'label_id', + self::LABELS_TABLE, 'id', + 'CASCADE', 'CASCADE' + ); + } + } + + public function safeDown(): void + { + $this->dropTable(self::ASSORTMENT_TABLE); + $this->dropTable(self::LABELS_TABLE); + } +} diff --git a/erp24/migrations/m260420_140000_create_product_scores_table.php b/erp24/migrations/m260420_140000_create_product_scores_table.php new file mode 100644 index 00000000..973656d4 --- /dev/null +++ b/erp24/migrations/m260420_140000_create_product_scores_table.php @@ -0,0 +1,45 @@ +db->getTableSchema(self::TABLE)) { + return; + } + + $this->createTable(self::TABLE, [ + 'id' => $this->primaryKey(), + 'mapping_id' => $this->integer()->notNull() + ->comment('Soft FK → product_mappings.id'), + 'score' => $this->smallInteger()->null() + ->comment('Оценка КМ 1-5, NULL = не выставлена'), + 'comment' => $this->text()->null() + ->comment('Комментарий категорийного менеджера'), + 'km_user_id' => $this->integer()->notNull() + ->comment('Автор оценки/комментария (admin.id)'), + 'km_comment_at' => $this->dateTime()->null() + ->comment('Время последнего редактирования комментария'), + 'created_at' => $this->dateTime()->notNull(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->execute('ALTER TABLE ' . self::TABLE . ' ADD CONSTRAINT chk_ps_score CHECK (score BETWEEN 1 AND 5)'); + + $this->createIndex('uq_product_scores_mapping', self::TABLE, 'mapping_id', true); + $this->createIndex('idx_product_scores_km_user', self::TABLE, 'km_user_id'); + } + + public function safeDown(): void + { + $this->dropTable(self::TABLE); + } +} diff --git a/erp24/migrations/m260420_150000_create_store_product_ratings_table.php b/erp24/migrations/m260420_150000_create_store_product_ratings_table.php new file mode 100644 index 00000000..93a57e7d --- /dev/null +++ b/erp24/migrations/m260420_150000_create_store_product_ratings_table.php @@ -0,0 +1,44 @@ +db->getTableSchema(self::TABLE)) { + return; + } + + $this->createTable(self::TABLE, [ + 'id' => $this->primaryKey(), + 'product_guid' => $this->string(255)->notNull() + ->comment('Soft FK → products_1c_nomenclature.id'), + 'store_id' => $this->integer()->notNull() + ->comment('FK → city_store.id'), + 'rating' => $this->decimal(3, 1)->notNull() + ->comment('Оценка магазина 1.0 – 5.0'), + 'created_by' => $this->integer()->notNull(), + 'created_at' => $this->dateTime()->notNull(), + 'updated_by' => $this->integer()->null(), + 'updated_at' => $this->dateTime()->null(), + ]); + + $this->execute('ALTER TABLE ' . self::TABLE . ' ADD CONSTRAINT chk_spr_rating CHECK (rating BETWEEN 1 AND 5)'); + + $this->createIndex('uq_store_product_ratings', self::TABLE, ['product_guid', 'store_id'], true); + $this->createIndex('idx_spr_product_guid', self::TABLE, 'product_guid'); + $this->createIndex('idx_spr_store_id', self::TABLE, 'store_id'); + } + + public function safeDown(): void + { + $this->dropTable(self::TABLE); + } +} diff --git a/erp24/migrations/m260422_100000_add_classification_fields_to_nomenclature.php b/erp24/migrations/m260422_100000_add_classification_fields_to_nomenclature.php new file mode 100644 index 00000000..a07587ed --- /dev/null +++ b/erp24/migrations/m260422_100000_add_classification_fields_to_nomenclature.php @@ -0,0 +1,47 @@ +db->getTableSchema(self::TABLE)->columns); + + if (!in_array('classification_status', $cols, true)) { + $this->addColumn(self::TABLE, 'classification_status', + $this->string(20)->notNull()->defaultValue('unclassified') + ->comment('not_needed | unclassified | pending | approved')); + } + if (!in_array('confidence', $cols, true)) { + $this->addColumn(self::TABLE, 'confidence', + $this->smallInteger()->null()->comment('Уверенность ML 0-100 %')); + } + if (!in_array('classified_by', $cols, true)) { + $this->addColumn(self::TABLE, 'classified_by', + $this->integer()->null()->comment('ID пользователя, подтвердившего разметку')); + } + if (!in_array('classified_at', $cols, true)) { + $this->addColumn(self::TABLE, 'classified_at', + $this->dateTime()->null()->comment('Дата подтверждения')); + } + + $this->createIndex(self::INDEX, self::TABLE, 'classification_status'); + } + + public function safeDown(): void + { + $this->dropIndex(self::INDEX, self::TABLE); + foreach (['classified_at', 'classified_by', 'confidence', 'classification_status'] as $col) { + $this->dropColumn(self::TABLE, $col); + } + } +} diff --git a/erp24/migrations/m260422_110000_add_catmanager_rbac.php b/erp24/migrations/m260422_110000_add_catmanager_rbac.php new file mode 100644 index 00000000..cc52f8ff --- /dev/null +++ b/erp24/migrations/m260422_110000_add_catmanager_rbac.php @@ -0,0 +1,74 @@ +upsertGroupPermissions(self::GROUP_CATMANAGER, [self::PERM_MANAGE, self::PERM_VIEW]); + + // Закупщик и Руководитель: добавьте их group_id аналогично, когда будут известны. + // Пример: $this->upsertGroupPermissions(, [self::PERM_VIEW]); + + \Yii::$app->cache->set('dirtyAuthSettings', true); + } + + public function safeDown(): void + { + $toRemove = [self::PERM_VIEW, self::PERM_MANAGE]; + $rows = $this->db->createCommand('SELECT id, config FROM admin_group_rbac_config')->queryAll(); + + foreach ($rows as $row) { + $perms = array_filter(array_map('trim', explode(',', (string)$row['config']))); + $cleaned = array_values(array_diff($perms, $toRemove)); + $this->update('admin_group_rbac_config', ['config' => implode(',', $cleaned)], ['id' => $row['id']]); + } + + \Yii::$app->cache->set('dirtyAuthSettings', true); + } + + private function upsertGroupPermissions(int $groupId, array $permissions): void + { + $existing = $this->db->createCommand( + 'SELECT config FROM admin_group_rbac_config WHERE admin_group_id = :gid', + [':gid' => $groupId] + )->queryScalar(); + + if ($existing === false) { + $this->insert('admin_group_rbac_config', [ + 'admin_group_id' => $groupId, + 'config' => implode(',', $permissions), + ]); + } else { + $current = array_filter(array_map('trim', explode(',', (string)$existing))); + $merged = array_values(array_unique(array_merge($current, $permissions))); + $this->update( + 'admin_group_rbac_config', + ['config' => implode(',', $merged)], + ['admin_group_id' => $groupId] + ); + } + } +} diff --git a/erp24/migrations/m260422_120000_create_product_concepts_table.php b/erp24/migrations/m260422_120000_create_product_concepts_table.php new file mode 100644 index 00000000..6ad898b7 --- /dev/null +++ b/erp24/migrations/m260422_120000_create_product_concepts_table.php @@ -0,0 +1,41 @@ +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); + } +} diff --git a/erp24/records/AdminGroup.php b/erp24/records/AdminGroup.php index 74e779b8..cbe966a4 100755 --- a/erp24/records/AdminGroup.php +++ b/erp24/records/AdminGroup.php @@ -38,6 +38,7 @@ class AdminGroup extends ActiveRecord const GROUP_OPERATIONAL_DIRECTOR = 51; const GROUP_IT = 81; const GROUP_FINANCE_DIRECTOR = 9; + const GROUP_CATMANAGER = 82; // ERP-337: Category Manager (КМ) — полный доступ к каталогу /** * Возвращает список специальных групп работников (нужно для JS и логики грейдов) diff --git a/erp24/records/AssortmentLabel.php b/erp24/records/AssortmentLabel.php new file mode 100644 index 00000000..b72af4cb --- /dev/null +++ b/erp24/records/AssortmentLabel.php @@ -0,0 +1,103 @@ + TimestampBehavior::class, + 'createdAtAttribute' => 'created_at', + 'updatedAtAttribute' => 'updated_at', + 'value' => new Expression('NOW()'), + ], + [ + 'class' => BlameableBehavior::class, + 'createdByAttribute' => 'created_by', + 'updatedByAttribute' => 'updated_by', + ], + ]; + } + + public function rules(): array + { + return [ + [['name'], 'required'], + [['name'], 'string', 'max' => 100], + [['channel_type'], 'in', 'range' => [ + self::CHANNEL_OFFLINE, self::CHANNEL_ONLINE, self::CHANNEL_MARKETPLACE, + ], 'skipOnEmpty' => true], + [['color'], 'match', 'pattern' => '/^#[0-9A-Fa-f]{6}$/', 'skipOnEmpty' => true], + [['icon'], 'string', 'max' => 50], + [['is_active'], 'boolean'], + [['channel_type', 'color', 'icon'], 'default', 'value' => null], + [['is_active'], 'default', 'value' => true], + ]; + } + + public function attributeLabels(): array + { + return [ + 'id' => 'ID', + 'name' => 'Название', + 'channel_type' => 'Тип канала', + 'color' => 'Цвет', + 'icon' => 'Иконка', + 'is_active' => 'Активен', + 'created_at' => 'Создан', + 'updated_at' => 'Обновлён', + 'created_by' => 'Создал', + 'updated_by' => 'Обновил', + ]; + } + + public function getProductAssortments(): ActiveQuery + { + return $this->hasMany(ProductAssortment::class, ['label_id' => 'id']); + } + + /** Возвращает активные лейблы, отсортированные по имени. */ + public static function findActive(): array + { + return static::find() + ->where(['is_active' => true]) + ->orderBy(['name' => SORT_ASC]) + ->all(); + } +} diff --git a/erp24/records/AuditLog.php b/erp24/records/AuditLog.php new file mode 100644 index 00000000..1e40f1ec --- /dev/null +++ b/erp24/records/AuditLog.php @@ -0,0 +1,97 @@ + 100], + [['entity_id'], 'string', 'max' => 255], + [['action'], 'string', 'max' => 50], + [['user_id'], 'integer'], + [['old_values', 'new_values'], 'safe'], + [['created_at'], 'safe'], + ]; + } + + /** + * Записывает событие в audit_log. + * Ошибки записи логируются через Yii::error, но не бросают исключений — + * аудит не должен прерывать основную бизнес-логику. + */ + public static function write( + string $entityType, + mixed $entityId, + string $action, + array $oldValues = [], + array $newValues = [], + ): void { + try { + $app = Yii::$app; + $userId = null; + $userRole = null; + + if ($app->has('user') && !$app->user->isGuest) { + $identity = $app->user->identity; + $userId = $identity->id ?? null; + // Имя группы из связи adminGroup (Admin hasOne AdminGroup) + $userRole = $identity->adminGroup->name ?? null; + } + + $ip = $app->has('request') ? ($app->request->userIP ?? null) : null; + + $record = new self([ + 'entity_type' => $entityType, + 'entity_id' => $entityId !== null ? (string)$entityId : null, + 'action' => $action, + 'old_values' => $oldValues ?: null, + 'new_values' => $newValues ?: null, + 'user_id' => $userId, + 'user_role' => $userRole, + 'ip_address' => $ip, + 'created_at' => date('Y-m-d H:i:s'), + ]); + + if (!$record->save()) { + Yii::error( + 'AuditLog write failed: ' . json_encode($record->getErrors(), JSON_UNESCAPED_UNICODE), + 'audit' + ); + } + } catch (\Throwable $e) { + Yii::error('AuditLog exception: ' . $e->getMessage(), 'audit'); + } + } +} diff --git a/erp24/records/ProductAssortment.php b/erp24/records/ProductAssortment.php new file mode 100644 index 00000000..0c06e5ab --- /dev/null +++ b/erp24/records/ProductAssortment.php @@ -0,0 +1,81 @@ + TimestampBehavior::class, + 'createdAtAttribute' => 'created_at', + 'updatedAtAttribute' => false, + 'value' => new Expression('NOW()'), + ], + [ + 'class' => BlameableBehavior::class, + 'createdByAttribute' => 'created_by', + 'updatedByAttribute' => false, + ], + ]; + } + + public function rules(): array + { + return [ + [['product_guid', 'label_id'], 'required'], + [['label_id', 'created_by'], 'integer'], + [['product_guid'], 'string', 'max' => 255], + [['product_guid', 'label_id'], 'unique', 'targetAttribute' => ['product_guid', 'label_id']], + ]; + } + + public function getLabel(): ActiveQuery + { + return $this->hasOne(AssortmentLabel::class, ['id' => 'label_id']); + } + + public function getProduct(): ActiveQuery + { + return $this->hasOne(Products1cNomenclature::class, ['id' => 'product_guid']); + } + + /** + * Возвращает все лейблы товара (с eager-load AssortmentLabel). + * + * @return AssortmentLabel[] + */ + public static function findLabelsForProduct(string $productGuid): array + { + return static::find() + ->with('label') + ->where(['product_guid' => $productGuid]) + ->all(); + } +} diff --git a/erp24/records/ProductConcept.php b/erp24/records/ProductConcept.php new file mode 100644 index 00000000..dfc3e95a --- /dev/null +++ b/erp24/records/ProductConcept.php @@ -0,0 +1,30 @@ + 255], + [['synced_at'], 'safe'], + ]; + } +} diff --git a/erp24/records/ProductScore.php b/erp24/records/ProductScore.php new file mode 100644 index 00000000..ff021988 --- /dev/null +++ b/erp24/records/ProductScore.php @@ -0,0 +1,66 @@ + 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]); + } +} diff --git a/erp24/records/Products1cNomenclature.php b/erp24/records/Products1cNomenclature.php index 36c39858..016e5a8a 100644 --- a/erp24/records/Products1cNomenclature.php +++ b/erp24/records/Products1cNomenclature.php @@ -20,6 +20,10 @@ use yii\db\ActiveQuery; * @property int|null $size Размер * @property string|null $measure Единица измерения * @property string|null $color Цвет + * @property string $classification_status not_needed|unclassified|pending|approved + * @property int|null $confidence Уверенность ML 0-100 % + * @property int|null $classified_by ID пользователя + * @property string|null $classified_at Дата подтверждения */ class Products1cNomenclature extends \yii\db\ActiveRecord { @@ -48,11 +52,15 @@ class Products1cNomenclature extends \yii\db\ActiveRecord return [ [['id', 'location', 'name', 'type_num', 'category'], 'required'], [['size', 'species', 'subcategory', 'sort', 'measure', 'color', 'type'], 'default', 'value' => null], - [['size'], 'integer'], + [['size', 'confidence', 'classified_by'], 'integer'], + [['confidence'], 'integer', 'min' => 0, 'max' => 100], [['id', 'location', 'name', 'type_num', 'category', 'subcategory', 'species', 'sort', 'measure', 'color', 'type'], 'string', 'max' => 255], [['id'], 'unique'], - [['date_from','date_to'], 'safe'], + [['date_from', 'date_to', 'classified_at'], 'safe'], + ['classification_status', 'in', 'range' => ['not_needed', 'unclassified', 'pending', 'approved']], + ['classification_status', 'default', 'value' => 'unclassified'], + [['confidence', 'classified_by', 'classified_at'], 'default', 'value' => null], ]; } diff --git a/erp24/records/Products1cNomenclatureActuality.php b/erp24/records/Products1cNomenclatureActuality.php index ba043e96..3f0b8062 100644 --- a/erp24/records/Products1cNomenclatureActuality.php +++ b/erp24/records/Products1cNomenclatureActuality.php @@ -3,6 +3,7 @@ namespace yii_app\records; use Yii; +use yii_app\traits\AuditableTrait; /** * This is the model class for table "products_1c_nomenclature_actuality". @@ -18,6 +19,8 @@ use Yii; */ class Products1cNomenclatureActuality extends \yii\db\ActiveRecord { + use AuditableTrait; + /** @@ -71,4 +74,27 @@ class Products1cNomenclatureActuality extends \yii\db\ActiveRecord ); } + public function getStatus(): string + { + $now = date('Y-m-d H:i:s'); + if ($this->date_from <= $now && ($this->date_to === null || $this->date_to >= $now)) { + return 'active'; + } + return $this->date_from > $now ? 'future' : 'past'; + } + + public function getLabel(): string + { + static $months = [ + 1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр', + 5 => 'Май', 6 => 'Июн', 7 => 'Июл', 8 => 'Авг', + 9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек', + ]; + $from = new \DateTime($this->date_from); + $to = new \DateTime($this->date_to ?? $this->date_from); + return $months[(int)$from->format('n')] . ' ' . $from->format('Y') + . ' – ' + . $months[(int)$to->format('n')] . ' ' . $to->format('Y'); + } + } diff --git a/erp24/records/StoreProductRating.php b/erp24/records/StoreProductRating.php new file mode 100644 index 00000000..dd9fdc80 --- /dev/null +++ b/erp24/records/StoreProductRating.php @@ -0,0 +1,70 @@ + TimestampBehavior::class, + 'createdAtAttribute' => 'created_at', + 'updatedAtAttribute' => 'updated_at', + 'value' => new Expression('NOW()'), + ], + [ + 'class' => BlameableBehavior::class, + 'createdByAttribute' => 'created_by', + 'updatedByAttribute' => 'updated_by', + ], + ]; + } + + public function rules(): array + { + return [ + [['product_guid', 'store_id', 'rating'], 'required'], + ['product_guid', 'string', 'max' => 255], + ['store_id', 'integer'], + ['rating', 'number', 'min' => 1, 'max' => 5], + ]; + } + + public static function avgForProduct(string $productGuid): ?float + { + $avg = static::find() + ->where(['product_guid' => $productGuid]) + ->average('rating'); + + return $avg !== null ? round((float)$avg, 2) : null; + } +} diff --git a/erp24/traits/AuditableTrait.php b/erp24/traits/AuditableTrait.php new file mode 100644 index 00000000..12358774 --- /dev/null +++ b/erp24/traits/AuditableTrait.php @@ -0,0 +1,95 @@ + 'active']); + */ +trait AuditableTrait +{ + /** + * Логирует создание/обновление. + * Для update пишет только реально изменённые атрибуты — не срабатывает на no-op save. + */ + public function afterSave(bool $insert, array $changedAttributes): void + { + parent::afterSave($insert, $changedAttributes); + + if ($insert) { + AuditLog::write( + entityType: static::tableName(), + entityId: $this->getPrimaryKey(), + action: AuditLog::ACTION_CREATE, + newValues: $this->getAttributes(), + ); + return; + } + + // No-op save — ничего не изменилось, не пишем + if (empty($changedAttributes)) { + return; + } + + // Берём новые значения только для изменённых атрибутов + $newValues = array_intersect_key($this->getAttributes(), $changedAttributes); + + AuditLog::write( + entityType: static::tableName(), + entityId: $this->getPrimaryKey(), + action: AuditLog::ACTION_UPDATE, + oldValues: $changedAttributes, + newValues: $newValues, + ); + } + + /** + * Логирует удаление записи. + */ + public function afterDelete(): void + { + parent::afterDelete(); + + AuditLog::write( + entityType: static::tableName(), + entityId: $this->getPrimaryKey(), + action: AuditLog::ACTION_DELETE, + oldValues: $this->getAttributes(), + ); + } + + /** + * Логирует массовую операцию. + * Пишет одну запись с массивом entity_ids в new_values. + * + * @param array $entityIds Список PK затронутых записей + * @param array $oldValues Общие старые значения (если применимо) + * @param array $newValues Применённые изменения + */ + public static function logBulkUpdate( + array $entityIds, + array $oldValues = [], + array $newValues = [], + ): void { + AuditLog::write( + entityType: static::tableName(), + entityId: null, + action: AuditLog::ACTION_BULK_UPDATE, + oldValues: $oldValues, + newValues: array_merge($newValues, ['entity_ids' => $entityIds]), + ); + } +} diff --git a/erp24/views/assortment-label/index.php b/erp24/views/assortment-label/index.php new file mode 100644 index 00000000..1fd00206 --- /dev/null +++ b/erp24/views/assortment-label/index.php @@ -0,0 +1,142 @@ +title = 'Справочник лейблов ассортиментной матрицы'; +$this->params['breadcrumbs'][] = $this->title; +$this->registerJsFile('/js/assortmentLabel/index.js', ['position' => View::POS_END]); +?> + +
+ +
+

title) ?>

+ +
+ + +
+
+
+
%
+
покрытие матрицей
+
+
+
+
+
+
товаров с лейблом
+
+
+
+
+
+
всего товаров
+
+
+
+ + +
+
+ Загрузка... +
+
+ +
+ + + + + + + + +
+ + diff --git a/erp24/views/products1c-nomenclature-actuality/index.php b/erp24/views/products1c-nomenclature-actuality/index.php index 70667730..8b1c9c19 100644 --- a/erp24/views/products1c-nomenclature-actuality/index.php +++ b/erp24/views/products1c-nomenclature-actuality/index.php @@ -3,9 +3,8 @@ use kartik\form\ActiveForm; use kartik\grid\GridView; use yii\helpers\Html; +use yii\helpers\Url; use yii\web\View; -use yii_app\records\Products1cNomenclatureActuality; - /* @var $this yii\web\View */ /* @var $filter yii\base\DynamicModel */ @@ -17,20 +16,24 @@ use yii_app\records\Products1cNomenclatureActuality; /* @var array $colors */ /* @var array $sorts */ /* @var array $sizes */ +/* @var array|null $counters */ +/* @var int $pageSize */ +/* @var string $sortBy */ $this->title = 'Актуализация номенклатуры'; $this->params['breadcrumbs'][] = $this->title; $this->registerJsFile('/js/products1cNomenclatureActuality/index.js', ['position' => View::POS_END]); -// Список месяцев-годов для выпадающих списков -function monthList() +$this->registerCss('.concept-chip{display:inline-block;font-size:10px;padding:2px 7px;border-radius:4px;font-weight:500;background:#f3e8ff;color:#7c3aed;border:1px dashed #d8b4fe;}'); + +function monthList(): array { $list = []; - $tz = new DateTimeZone('Europe/Moscow'); - $now = new DateTime('now', $tz); + $tz = new DateTimeZone('Europe/Moscow'); + $now = new DateTime('now', $tz); $start = (clone $now)->modify('first day of january last year'); - $end = (clone $now)->modify('last day of december next year'); + $end = (clone $now)->modify('last day of december next year'); while ($start <= $end) { - $key = $start->format('Y-m'); + $key = $start->format('Y-m'); $list[$key] = $start->format('Y‑m'); $start->modify('+1 month'); } @@ -38,9 +41,74 @@ function monthList() } $months = monthList(); -$monthOptions = ''; -foreach ($months as $k => $v) { - $monthOptions .= ""; + +function renderActualityChips(array $actualities): string +{ + if (empty($actualities)) { + return ''; + } + + $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[] = '' . Html::encode($act->getLabel()) . ''; + } + + $visible = array_slice($chips, 0, 3); + $hidden = count($chips) - count($visible); + $html = implode('', $visible); + if ($hidden > 0) { + $html .= '+' . $hidden . ' ещё'; + } + return $html; +} + +function renderConceptChips(array $conceptNames): string +{ + if (empty($conceptNames)) { + return ''; + } + $chips = ''; + foreach ($conceptNames as $name) { + $chips .= '' + . '' + . Html::encode($name) + . ''; + } + return '' . $chips . ''; +} + +function renderLabelChips(array $labels, string $productGuid): string +{ + if (empty($labels)) { + return '—'; + } + + $html = ''; + foreach ($labels as $label) { + $color = Html::encode($label->color ?? '#6c757d'); + $name = Html::encode($label->name); + $labelId = (int)$label->id; + $html .= '' + . $name + . '' + . ''; + } + return '' . $html . ''; } ?> @@ -48,17 +116,34 @@ foreach ($months as $k => $v) {

title) ?>

- + + + 'get', - 'action' => ['index'], + 'method' => 'get', + 'action' => ['index'], 'options' => ['class' => 'mb-4'], ]); ?>
- -
+
Номенклатура
@@ -67,8 +152,7 @@ foreach ($months as $k => $v) { $categories, ['prompt' => 'Категория', 'id' => 'filter-category'] )->label(false) ?> - -
+
@@ -79,8 +163,7 @@ foreach ($months as $k => $v) { $types, ['prompt' => 'Тип', 'id' => 'filter-type'] )->label(false) ?> - -
+
@@ -91,8 +174,7 @@ foreach ($months as $k => $v) { $colors, ['prompt' => 'Цвет', 'id' => 'filter-color'] )->label(false) ?> - -
+
@@ -103,10 +185,9 @@ foreach ($months as $k => $v) {
field($filter, 'subcategory', ['options' => ['class' => 'w-90']])->dropDownList( $subcategories, - ['prompt' => 'Подкатегория', 'id' => 'filter-subcategory', 'class' => 'w-100'] + ['prompt' => 'Подкатегория', 'id' => 'filter-subcategory'] )->label(false) ?> - -
+
@@ -117,7 +198,7 @@ foreach ($months as $k => $v) { $sorts, ['prompt' => 'Сорт', 'id' => 'filter-sort'] )->label(false) ?> -
+
@@ -131,7 +212,7 @@ foreach ($months as $k => $v) { $species, ['prompt' => 'Вид', 'id' => 'filter-species'] )->label(false) ?> -
+
@@ -142,7 +223,7 @@ foreach ($months as $k => $v) { $sizes, ['prompt' => 'Размер', 'id' => 'filter-size'] )->label(false) ?> -
+
@@ -151,21 +232,14 @@ foreach ($months as $k => $v) {
- -
+
Актуальность ассортимента
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) ?> -
+
@@ -173,183 +247,364 @@ foreach ($months as $k => $v) {
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) ?> -
+
field($filter, 'onlyActive')->checkbox([ - 'label' => 'Только активные', + 'label' => 'Только активные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyActive, - 'id' => 'onlyActiveCheckbox' + 'id' => 'onlyActiveCheckbox', ])->label(false) ?> - field($filter, 'onlyInactive')->checkbox([ - 'label' => 'Только неактивные', + 'label' => 'Только неактивные', 'uncheck' => 0, 'checked' => (bool)$filter->onlyInactive, - 'id' => 'onlyInactiveCheckbox' + 'id' => 'onlyInactiveCheckbox', ])->label(false) ?>
-
Поставщики
-
'form-select', 'id' => 'filter-supplier', 'prompt' => 'Поставщик']) ?> -
+
-
'form-select', 'id' => 'filter-plantation', 'prompt' => 'Плантация']) ?> -
+
-
-
- 'btn btn-primary w-100']) ?> +
+ 'btn btn-primary w-100']) ?> + + XLSX + +
+
+ +
+
+ field($filter, 'search', ['options' => ['class' => 'mb-0']]) + ->textInput(['placeholder' => 'Поиск по наименованию...', 'id' => 'filter-search']) + ->label(false) ?> +
+
+
+ + 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) ?> +
+
+ + 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) ?> +
- - - - - 'actuality-form']); ?> -
- 'btn btn-success', 'id' => 'saveButton']) ?> + +
+
+
+
+
Всего
+
+
+
+
+
+
Активных
+
+
+
+
+
+
Без матрицы
+
+
+ +
+
+
+
Без оценки
+
+
+
+ + + +
+ 0 выбрано + + + + +
+ $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 '
' . $name . $btn . '
'; - } + 'format' => 'raw', + 'headerOptions' => ['style' => 'width:36px;text-align:center;vertical-align:middle'], + 'contentOptions' => ['style' => 'width:36px;text-align:center;vertical-align:middle'], + 'header' => '', + 'value' => function ($row) { + return ''; + }, ], [ - '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 ? '
- -
' : ''; - $inputs = '
'; - $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 .= '
'; - 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 = '' . renderActualityChips($acts) . ''; + $btn = Html::button('', [ + 'class' => 'btn btn-xs btn-outline-secondary ms-2 open-intervals-modal', + 'title' => 'Редактировать интервалы', + 'data-guid' => $p->id, + 'data-name' => $p->name, ]); - } + return '
' . $chips . $btn . '
'; + }, ], [ - '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 '' + . renderLabelChips($labels, $p->id) + . renderConceptChips($concepts) + . ''; + }, + ], + [ + 'label' => 'Склад NN', + 'format' => 'raw', + 'contentOptions' => ['style' => 'width:60px; text-align:center;'], + 'value' => fn() => '', + ], + [ + 'label' => 'Склад MSK', + 'format' => 'raw', + 'contentOptions' => ['style' => 'width:60px; text-align:center;'], + 'value' => fn() => '', ], [ - 'label' => 'Поставщик/Плантация', + 'label' => 'Поставщик/Плантация', 'format' => 'text', - 'contentOptions' => ['style'=>'min-width:150px;'], - 'value' => function ($m) { - return '–'; - } + 'contentOptions' => ['style' => 'min-width:150px;'], + 'value' => fn() => '–', ], ], ]); ?> +
+ + + + +
+ + diff --git a/erp24/views/products1c-nomenclature-markup/index.php b/erp24/views/products1c-nomenclature-markup/index.php new file mode 100644 index 00000000..32f5bc5d --- /dev/null +++ b/erp24/views/products1c-nomenclature-markup/index.php @@ -0,0 +1,355 @@ +title = 'Разметка номенклатуры'; +$this->params['breadcrumbs'][] = $this->title; +$this->registerJsFile('/js/products1cNomenclatureMarkup/index.js', ['position' => View::POS_END]); + +$statusLabels = MarkupCtrl::statusLabels(); +?> + +
+ +

title) ?>

+ + + + + +
+
+
+
+
Не нужна разметка
+
Аксессуары, упаковка
+
+
+
+
+
+
Не размечены
+
Разметка не выполнена
+
+
+
+
+
+
Требуют подтверждения
+
Ожидает проверки КМ
+
+
+
+
+
+
Подтверждены
+
Финализированы
+
+
+
+ + + 'get', 'action' => ['index'], 'options' => ['class' => 'mb-3']]); ?> +
+
+ + field($filter, 'category', ['options' => ['class' => 'mb-0']]) + ->dropDownList($categories, ['prompt' => 'Все', 'class' => 'form-select form-select-sm']) + ->label(false) ?> +
+
+ + field($filter, 'species', ['options' => ['class' => 'mb-0']]) + ->dropDownList($speciesList, ['prompt' => 'Все', 'class' => 'form-select form-select-sm']) + ->label(false) ?> +
+
+ + 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) ?> +
+
+ + field($filter, 'confidence_range', ['options' => ['class' => 'mb-0']]) + ->dropDownList([ + '' => 'Все', + 'high' => '≥ 90%', + 'medium' => '70–89%', + 'low' => '< 70%', + ], ['class' => 'form-select form-select-sm']) + ->label(false) ?> +
+
+ + field($filter, 'pageSize', ['options' => ['class' => 'mb-0']]) + ->dropDownList([50 => '50', 100 => '100', 500 => '500'], ['class' => 'form-select form-select-sm']) + ->label(false) ?> +
+ 'btn btn-primary btn-sm']) ?> + Сбросить +
+ + + +
+ 0 выбрано + + +
+ + $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' => '', + 'value' => fn($m) => '', + ], + [ + 'label' => 'Товар', + 'format' => 'raw', + 'contentOptions' => ['style' => 'min-width:180px'], + 'value' => fn($m) => '' . Html::encode($m->name) . '
' + . '' . Html::encode(substr($m->id, 0, 12)) . '…', + ], + [ + '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 '' . Html::encode($label . $conf) . ''; + }, + ], + [ + '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 = ''; + $approveBtn = $status === 'pending' + ? '' + : ''; + $notNeededBtn = in_array($status, ['pending', 'unclassified'], true) + ? '' + : ''; + $revertBtn = $status === 'not_needed' + ? '' + : ''; + return $approveBtn . $editBtn . $notNeededBtn . $revertBtn; + }, + ], + ], + ]); ?> + +
+ + + + + + + + +
+ + diff --git a/erp24/web/js/assortmentLabel/index.js b/erp24/web/js/assortmentLabel/index.js new file mode 100644 index 00000000..59068ebb --- /dev/null +++ b/erp24/web/js/assortmentLabel/index.js @@ -0,0 +1,260 @@ +document.addEventListener('DOMContentLoaded', () => { + + const cfg = window.assortmentLabelConfig || {}; + const urls = cfg.urls || {}; + + // ─── Toast ────────────────────────────────────────────────────────────── + + function showToast(message, type = 'danger') { + const id = 'toast-' + Date.now(); + const cls = type === 'success' ? 'bg-success text-white' : 'bg-danger text-white'; + const html = ` + `; + $('#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 '—'; + const cls = CHANNEL_CLASS[type] || 'bg-secondary'; + return `${type}`; + } + + // ─── Render table ──────────────────────────────────────────────────────── + + function renderTable(labels) { + if (!labels.length) { + $('#labelsTableWrap').html('
Лейблов нет. Создайте первый.
'); + return; + } + + const rows = labels.map(l => { + const color = l.color || '#6c757d'; + const colorDot = ``; + const icon = l.icon + ? `${l.icon}` + : '—'; + const activeBadge = l.is_active + ? 'активен' + : 'неактивен'; + 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 ` + + ${colorDot} + ${l.name} + ${channelBadge(l.channel_type)} + ${icon} + ${l.product_count} + ${activeBadge} + + + + + + `; + }).join(''); + + const html = ` + + + + + + + + + + + + + ${rows} +
НазваниеКаналИконкаТоваровСтатусДействия
`; + + $('#labelsTableWrap').html(html); + } + + // ─── Load list ─────────────────────────────────────────────────────────── + + function loadLabels() { + $('#labelsTableWrap').html( + '
Загрузка...
' + ); + $.get(urls.list, data => { + if (data.success) { + renderTable(data.labels); + } else { + $('#labelsTableWrap').html('
Ошибка загрузки
'); + } + }).fail(() => { + $('#labelsTableWrap').html('
Ошибка запроса
'); + }); + } + + 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('Ошибка запроса'); + }); + }); + +}); diff --git a/erp24/web/js/products1cNomenclatureActuality/index.js b/erp24/web/js/products1cNomenclatureActuality/index.js index 7247527f..09db3741 100644 --- a/erp24/web/js/products1cNomenclatureActuality/index.js +++ b/erp24/web/js/products1cNomenclatureActuality/index.js @@ -1,304 +1,674 @@ -document.addEventListener("DOMContentLoaded", () => { - - const monthOptions = Object.entries(window.productActualityConfig.months || {}).map( - ([k, v]) => `` - ).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 = ` - - -
${name} (${guid})
Новая запись. Заполните интервал
- - - -
- - -
- - - - - - - - – - - `; - - 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 = ` + `; + $('#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 ''; } - }); + 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 `${iv.label}`; + }).join(''); + if (hidden > 0) html += `+${hidden} ещё`; + 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 '—'; + return '' + labels.map(l => { + const color = l.color || '#6c757d'; + return ` + ${l.name} + + `; + }).join('') + ''; + } + + 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]) => + `` + ).join(''); + } + + function initMonthSelects(fromVal = '', toVal = '') { + const opts = buildMonthOptions(); + $fromSel.html('' + opts).val(fromVal); + $toSel.html('' + 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 `${labels[status] || status}`; } - 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 '

Интервалов нет. Добавьте первый.

'; + } + const rows = intervals.map((iv, i) => { + const style = iv.status === 'past' ? ' style="text-decoration:line-through;opacity:.7"' : ''; + return ` + + ${i + 1} + ${iv.label} + ${statusBadge(iv.status)} + + + + + `; + }).join(''); + + return ` + + + + + + + + + + ${rows} +
#ПериодСтатусДействия
`; + } + + function loadIntervals(guid) { + $tableContainer.html('
Загрузка...
'); + $.get(urls.intervals, { guid }, data => { + if (data.success) { + currentIntervals = data.intervals; + $tableContainer.html(renderIntervalsTable(currentIntervals)); + } else { + $tableContainer.html('
Ошибка загрузки
'); } + }).fail(() => { + $tableContainer.html('
Ошибка запроса
'); }); - intervals.sort((a,b) => a.from.localeCompare(b.from)); - - let hasOverlap = false; - for(let i=0; i= intervals[j].from) { - // Пересечение! - intervals[i].$row.addClass('table-danger'); - intervals[j].$row.addClass('table-danger'); - hasOverlap = true; - } - } - } - if (hasOverlap) { - if (!$('.interval-overlap-alert').length) { - $('
Пересекающиеся диапазоны по одному товару!
') - .insertBefore('#actuality-form'); + } + + function loadLabels(guid) { + $('#labelsContainer').html('
Загрузка...
'); + $.get(urls.labels, { guid }, data => { + if (!data.success) { + $('#labelsContainer').html('
Ошибка загрузки
'); + 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 = ` `; + return `
+ + +
`; + }).join(''); + $('#labelsContainer').html(rows || '

Нет доступных лейблов

'); + }).fail(() => { + $('#labelsContainer').html('
Ошибка запроса
'); + }); } - $(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 += ``; + } + 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 = '
'; + for (let i = 1; i <= 5; i++) { + const active = currentScore >= i ? ' text-warning' : ' text-muted'; + html += ``; + } + html += ''; + html += '
'; + return html; } + function renderScoreAccordion(mappings, accordionId) { + if (!mappings.length) { + return '

Маппингов нет. Добавьте через справочник поставщиков.

'; + } - function getMonthsMap() { - return { ...(window.productActualityConfig?.months || {}) }; + return mappings.map((m, i) => { + const deleted = !!m.deleted_at; + const rowCls = deleted ? 'opacity-50' : ''; + const deletedBadge = deleted ? 'Удалён' : ''; + const label = [m.supplier_name, m.plantation_name, m.supplier_product_name].filter(Boolean).join(' · '); + const scoreStr = m.score ? starsHtml(m.score) : 'Без оценки'; + const comment = m.comment ? `

${m.comment}

` : ''; + const editedAt = m.km_comment_at ? `Изменено: ${m.km_comment_at}` : ''; + + return ` +
+

+ +

+
+
+ ${deleted ? '
Маппинг удалён. Оценка сохранена.
' : ''} + ${renderStarPicker(m.id, m.score)} + +
+ + ${editedAt} +
+
+
+
`; + }).join(''); } + function loadScores(guid) { + $('#scoresContainer').html('
Загрузка...
'); + $('#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('
Ошибка загрузки
'); + 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 = `
${renderScoreAccordion(data.mappings, accId)}
`; + $('#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('
Ошибка запроса
'); + }); } - function buildMonthOptions(monthsMap, minYM) { - const keys = Object.keys(monthsMap).sort().filter(k => !minYM || k >= minYM); - return keys.map(k => ``).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 ? '' : ''; - 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('
Загрузка...
'); + $('#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('—'); + } + // 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( + $('