--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\commands;
+
+use yii\console\Controller;
+use yii\console\ExitCode;
+use yii\helpers\Console;
+use yii_app\services\ProductMappingService;
+
+/**
+ * Console-команды для маппинга товаров.
+ *
+ * Примеры:
+ * php yii product-mapping/cleanup-orphans # dry-run: только список orphans
+ * php yii product-mapping/cleanup-orphans --delete # реальное удаление
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-323
+ */
+class ProductMappingController extends Controller
+{
+ /**
+ * Удалять ли найденные orphan-маппинги.
+ * По умолчанию false — команда только показывает, что собирается удалить.
+ */
+ public bool $delete = false;
+
+ public function options($actionID): array
+ {
+ return array_merge(parent::options($actionID), ['delete']);
+ }
+
+ public function optionAliases(): array
+ {
+ return array_merge(parent::optionAliases(), ['d' => 'delete']);
+ }
+
+ /**
+ * Очистка «висячих» маппингов (product_guid не существует в products_1c).
+ *
+ * По умолчанию — dry-run: выводит список orphans без изменений в БД.
+ * С флагом --delete (или -d) — удаляет orphans (CASCADE уберёт и junction-записи).
+ *
+ * @return int exit code
+ */
+ public function actionCleanupOrphans(): int
+ {
+ $service = new ProductMappingService();
+
+ $this->stdout("Поиск orphan-маппингов...\n", Console::FG_CYAN);
+
+ $orphans = $service->findOrphanMappings();
+ $count = count($orphans);
+
+ if ($count === 0) {
+ $this->stdout("Orphans не найдены. Всё чисто.\n", Console::FG_GREEN);
+ return ExitCode::OK;
+ }
+
+ $this->stdout("Найдено orphans: {$count}\n", Console::FG_YELLOW);
+ $this->stdout(str_repeat('-', 80) . "\n");
+ $this->stdout(sprintf("%-8s %-40s %-12s %s\n", 'ID', 'PRODUCT_GUID', 'SUPPLIER', 'НАЗВАНИЕ'));
+ $this->stdout(str_repeat('-', 80) . "\n");
+
+ $limit = 50;
+ foreach (array_slice($orphans, 0, $limit) as $o) {
+ $this->stdout(sprintf(
+ "%-8d %-40s %-12d %s\n",
+ (int)$o['id'],
+ (string)$o['product_guid'],
+ (int)$o['supplier_id'],
+ mb_substr((string)$o['supplier_product_name'], 0, 60)
+ ));
+ }
+ if ($count > $limit) {
+ $this->stdout(sprintf("... и ещё %d записей\n", $count - $limit), Console::FG_GREY);
+ }
+ $this->stdout(str_repeat('-', 80) . "\n");
+
+ if (!$this->delete) {
+ $this->stdout(
+ "Dry-run режим. Для удаления запустите команду с флагом --delete\n",
+ Console::FG_GREY
+ );
+ return ExitCode::OK;
+ }
+
+ $this->stdout("Удаление orphans...\n", Console::FG_CYAN);
+
+ try {
+ $deleted = $service->deleteOrphanMappings();
+ $this->stdout(
+ "Удалено маппингов: {$deleted}\n",
+ Console::FG_GREEN
+ );
+ $this->stdout(
+ "Связанные записи mapping_markings удалены каскадно.\n",
+ Console::FG_GREY
+ );
+ return ExitCode::OK;
+ } catch (\Throwable $e) {
+ $this->stderr("Ошибка: " . $e->getMessage() . "\n", Console::FG_RED);
+ return ExitCode::UNSPECIFIED_ERROR;
+ }
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii_app\records\Marking;
+use yii_app\records\MarkingSearch;
+use yii_app\records\Plantation;
+use yii_app\records\Producer;
+use yii_app\records\Supplier;
+use yii\filters\VerbFilter;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+
+/**
+ * CRUD контроллер справочника маркировок.
+ * Все write-операции — AJAX JSON.
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-320
+ */
+class MarkingController extends BaseController
+{
+ public function behaviors(): array
+ {
+ return array_merge(
+ parent::behaviors(),
+ [
+ 'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'create' => ['POST'],
+ 'update' => ['POST'],
+ 'delete' => ['POST'],
+ ],
+ ],
+ ]
+ );
+ }
+
+ public function actionIndex(): string
+ {
+ $searchModel = new MarkingSearch();
+ $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
+
+ if (Yii::$app->request->isAjax) {
+ return $this->renderAjax('/marking/index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ return $this->render('/marking/index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ public function actionCreateForm(): string
+ {
+ $model = new Marking();
+ $model->loadDefaultValues();
+
+ return $this->renderAjax('/marking/_form', [
+ 'model' => $model,
+ 'producers' => $this->getActiveProducers(),
+ 'plantations' => [],
+ 'suppliers' => $this->getActiveSuppliers(),
+ ]);
+ }
+
+ public function actionCreate(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = new Marking();
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Маркировка создана']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка создания маркировки: ' . $e->getMessage(), 'marking');
+ return $this->asJson(['success' => false, 'errors' => ['code' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionUpdateForm(int $id): string
+ {
+ $model = $this->findModel($id);
+
+ return $this->renderAjax('/marking/_form', [
+ 'model' => $model,
+ 'producers' => $this->getActiveProducers(),
+ 'plantations' => $this->getPlantationsByProducer((int)$model->producer_id),
+ 'suppliers' => $this->getActiveSuppliers(),
+ ]);
+ }
+
+ public function actionUpdate(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Маркировка обновлена']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка обновления маркировки: ' . $e->getMessage(), 'marking');
+ return $this->asJson(['success' => false, 'errors' => ['code' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionDelete(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+
+ if (!$model->is_active) {
+ return $this->asJson(['success' => false, 'message' => 'Маркировка уже деактивирована']);
+ }
+
+ try {
+ $model->deactivate();
+
+ return $this->asJson([
+ 'success' => true,
+ 'message' => sprintf('Маркировка "%s" деактивирована.', $model->code),
+ ]);
+ } catch (\RuntimeException $e) {
+ return $this->asJson(['success' => false, 'message' => $e->getMessage()]);
+ } catch (\Exception $e) {
+ Yii::error('Ошибка деактивации маркировки: ' . $e->getMessage(), 'marking');
+ return $this->asJson(['success' => false, 'message' => 'Ошибка деактивации: ' . $e->getMessage()]);
+ }
+ }
+
+ /**
+ * AJAX: список плантаций для выбранного производителя (для cascade select).
+ */
+ public function actionPlantationsByProducer(int $producer_id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $items = Plantation::find()
+ ->where(['producer_id' => $producer_id, 'is_active' => true])
+ ->orderBy(['name' => SORT_ASC])
+ ->select(['id', 'name', 'country'])
+ ->asArray()
+ ->all();
+
+ return $this->asJson(['success' => true, 'items' => $items]);
+ }
+
+ /* =====================================================
+ * HELPERS
+ * ===================================================== */
+
+ protected function findModel(int $id): Marking
+ {
+ if (($model = Marking::findOne(['id' => $id])) !== null) {
+ return $model;
+ }
+ throw new NotFoundHttpException('Маркировка не найдена.');
+ }
+
+ /** @return array<int,string> id => name */
+ protected function getActiveProducers(): array
+ {
+ return Producer::findActive()
+ ->orderBy(['name' => SORT_ASC])
+ ->select(['name', 'id'])
+ ->indexBy('id')
+ ->column();
+ }
+
+ /** @return array<int,string> id => name */
+ protected function getActiveSuppliers(): array
+ {
+ return Supplier::findActive()
+ ->orderBy(['name' => SORT_ASC])
+ ->select(['name', 'id'])
+ ->indexBy('id')
+ ->column();
+ }
+
+ /** @return array<int,string> id => name */
+ protected function getPlantationsByProducer(int $producerId): array
+ {
+ return Plantation::find()
+ ->where(['producer_id' => $producerId, 'is_active' => true])
+ ->orderBy(['name' => SORT_ASC])
+ ->select(['name', 'id'])
+ ->indexBy('id')
+ ->column();
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii_app\records\Plantation;
+use yii_app\records\Producer;
+use yii_app\records\ProducerSearch;
+use yii\filters\VerbFilter;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+
+/**
+ * CRUD контроллер справочника производителей и плантаций.
+ * Одна вкладка — две сущности (Producer + Plantation).
+ * Все write-операции — AJAX JSON.
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-319
+ */
+class ProducerController extends BaseController
+{
+ public function behaviors(): array
+ {
+ return array_merge(
+ parent::behaviors(),
+ [
+ 'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'create-producer' => ['POST'],
+ 'update-producer' => ['POST'],
+ 'delete-producer' => ['POST'],
+ 'create-plantation' => ['POST'],
+ 'update-plantation' => ['POST'],
+ 'delete-plantation' => ['POST'],
+ ],
+ ],
+ ]
+ );
+ }
+
+ /* =====================================================
+ * INDEX (таблица + модалка)
+ * ===================================================== */
+
+ public function actionIndex(): string
+ {
+ $searchModel = new ProducerSearch();
+ $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
+
+ if (Yii::$app->request->isAjax) {
+ return $this->renderAjax('/producer/index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ return $this->render('/producer/index', [
+ 'searchModel' => $searchModel,
+ 'dataProvider' => $dataProvider,
+ ]);
+ }
+
+ /* =====================================================
+ * PRODUCER CRUD
+ * ===================================================== */
+
+ public function actionCreateProducerForm(): string
+ {
+ $model = new Producer();
+ $model->loadDefaultValues();
+
+ return $this->renderAjax('/producer/_producer_form', ['model' => $model]);
+ }
+
+ public function actionCreateProducer(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = new Producer();
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Производитель создан']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка создания производителя: ' . $e->getMessage(), 'producer');
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionUpdateProducerForm(int $id): string
+ {
+ $model = $this->findProducer($id);
+ return $this->renderAjax('/producer/_producer_form', ['model' => $model]);
+ }
+
+ public function actionUpdateProducer(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findProducer($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Производитель обновлён']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка обновления производителя: ' . $e->getMessage(), 'producer');
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionDeleteProducer(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findProducer($id);
+
+ if (!$model->is_active) {
+ return $this->asJson(['success' => false, 'message' => 'Производитель уже деактивирован']);
+ }
+
+ try {
+ $result = $model->deactivate();
+
+ return $this->asJson([
+ 'success' => true,
+ 'message' => sprintf(
+ 'Производитель "%s" деактивирован. Плантаций деактивировано: %d.',
+ $model->name,
+ $result['plantations']
+ ),
+ ]);
+ } catch (\RuntimeException $e) {
+ return $this->asJson(['success' => false, 'message' => $e->getMessage()]);
+ } catch (\Exception $e) {
+ Yii::error('Ошибка деактивации производителя: ' . $e->getMessage(), 'producer');
+ return $this->asJson(['success' => false, 'message' => 'Ошибка деактивации: ' . $e->getMessage()]);
+ }
+ }
+
+ /* =====================================================
+ * PLANTATION CRUD
+ * ===================================================== */
+
+ public function actionCreatePlantationForm(?int $producerId = null): string
+ {
+ $model = new Plantation();
+ $model->loadDefaultValues();
+ if ($producerId !== null) {
+ $model->producer_id = $producerId;
+ }
+
+ return $this->renderAjax('/producer/_plantation_form', [
+ 'model' => $model,
+ 'producers' => $this->getActiveProducersList(),
+ ]);
+ }
+
+ public function actionCreatePlantation(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = new Plantation();
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Плантация создана']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка создания плантации: ' . $e->getMessage(), 'producer');
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionUpdatePlantationForm(int $id): string
+ {
+ $model = $this->findPlantation($id);
+ return $this->renderAjax('/producer/_plantation_form', [
+ 'model' => $model,
+ 'producers' => $this->getActiveProducersList(),
+ ]);
+ }
+
+ public function actionUpdatePlantation(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findPlantation($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Плантация обновлена']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка обновления плантации: ' . $e->getMessage(), 'producer');
+ return $this->asJson(['success' => false, 'errors' => ['name' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionDeletePlantation(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findPlantation($id);
+
+ if (!$model->is_active) {
+ return $this->asJson(['success' => false, 'message' => 'Плантация уже деактивирована']);
+ }
+
+ try {
+ $model->deactivate();
+
+ return $this->asJson([
+ 'success' => true,
+ 'message' => sprintf('Плантация "%s" деактивирована.', $model->name),
+ ]);
+ } catch (\RuntimeException $e) {
+ return $this->asJson(['success' => false, 'message' => $e->getMessage()]);
+ } catch (\Exception $e) {
+ Yii::error('Ошибка деактивации плантации: ' . $e->getMessage(), 'producer');
+ return $this->asJson(['success' => false, 'message' => 'Ошибка деактивации: ' . $e->getMessage()]);
+ }
+ }
+
+ /* =====================================================
+ * HELPERS
+ * ===================================================== */
+
+ protected function findProducer(int $id): Producer
+ {
+ if (($model = Producer::findOne(['id' => $id])) !== null) {
+ return $model;
+ }
+ throw new NotFoundHttpException('Производитель не найден.');
+ }
+
+ protected function findPlantation(int $id): Plantation
+ {
+ if (($model = Plantation::findOne(['id' => $id])) !== null) {
+ return $model;
+ }
+ throw new NotFoundHttpException('Плантация не найдена.');
+ }
+
+ /**
+ * Список активных производителей для select в модалке плантации.
+ *
+ * @return array<int,string> id => name
+ */
+ protected function getActiveProducersList(): array
+ {
+ return Producer::findActive()
+ ->orderBy(['name' => SORT_ASC])
+ ->select(['name', 'id'])
+ ->indexBy('id')
+ ->column();
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace app\controllers;
+
+use Yii;
+use yii_app\forms\ProductMappingFilterForm;
+use yii_app\records\Marking;
+use yii_app\records\Plantation;
+use yii_app\records\Products1cNomenclature;
+use yii_app\records\ProductMapping;
+use yii_app\records\Supplier;
+use yii_app\services\ProductMappingService;
+use yii\filters\VerbFilter;
+use yii\web\NotFoundHttpException;
+use yii\web\Response;
+
+/**
+ * CRUD контроллер маппинга товаров.
+ *
+ * Структура: карточки товаров из products_1c_nomenclature с вложенными
+ * таблицами маппингов (товар → много поставщиков → много маркировок).
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-321
+ */
+class ProductMappingController extends BaseController
+{
+ private const PER_PAGE_OPTIONS = [50, 100, 500];
+ private const PER_PAGE_DEFAULT = 50;
+
+ public function behaviors(): array
+ {
+ return array_merge(
+ parent::behaviors(),
+ [
+ 'verbs' => [
+ 'class' => VerbFilter::class,
+ 'actions' => [
+ 'create' => ['POST'],
+ 'update' => ['POST'],
+ 'delete' => ['POST'],
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Карточки товаров с вложенными таблицами маппингов + фильтры + аналитика.
+ */
+ public function actionIndex(): string
+ {
+ $perPage = (int)(Yii::$app->request->get('per-page', self::PER_PAGE_DEFAULT));
+ if (!in_array($perPage, self::PER_PAGE_OPTIONS, true)) {
+ $perPage = self::PER_PAGE_DEFAULT;
+ }
+
+ $filters = new ProductMappingFilterForm();
+ $filters->loadFilters(Yii::$app->request->get());
+
+ $service = new ProductMappingService();
+ $result = $service->getProductsWithMappings($filters, $perPage);
+ $analytics = $service->getAnalytics($filters);
+
+ $cascade = $service->getCascadeFilters($filters->category, $filters->subcategory);
+
+ $viewData = [
+ 'products' => $result['products'],
+ 'mappingsByGuid' => $result['mappingsByGuid'],
+ 'pagination' => $result['pagination'],
+ 'perPage' => $perPage,
+ 'perPageOptions' => self::PER_PAGE_OPTIONS,
+ 'filters' => $filters,
+ 'analytics' => $analytics,
+ 'categories' => $cascade['categories'],
+ 'subcategories' => $cascade['subcategories'],
+ 'speciesList' => $cascade['species'],
+ 'folders1c' => $service->getFolders1c(),
+ 'suppliers' => $this->getActiveSuppliers(),
+ 'markings' => $this->getActiveMarkings(),
+ ];
+
+ if (Yii::$app->request->isAjax) {
+ return $this->renderAjax('/product-mapping/index', $viewData);
+ }
+
+ return $this->render('/product-mapping/index', $viewData);
+ }
+
+ /**
+ * AJAX: пересчёт аналитики при изменении фильтров.
+ */
+ public function actionAnalytics(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $filters = new ProductMappingFilterForm();
+ $filters->loadFilters(Yii::$app->request->get());
+
+ $service = new ProductMappingService();
+ return $this->asJson($service->getAnalytics($filters));
+ }
+
+ /**
+ * AJAX: каскадные справочники фильтров (категории → подкатегории → виды).
+ */
+ public function actionCascadeFilters(?string $category = null, ?string $subcategory = null): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $service = new ProductMappingService();
+ return $this->asJson($service->getCascadeFilters($category, $subcategory));
+ }
+
+ /**
+ * Экспорт маппинга в .xlsx с учётом текущих фильтров.
+ */
+ public function actionExport(): Response
+ {
+ $filters = new ProductMappingFilterForm();
+ $filters->loadFilters(Yii::$app->request->get());
+
+ $service = new ProductMappingService();
+ $path = $service->exportToXlsx($filters);
+
+ $fileName = 'product-mapping-' . date('Y-m-d_His') . '.xlsx';
+
+ return Yii::$app->response
+ ->sendFile($path, $fileName, [
+ 'mimeType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ])
+ ->on(Response::EVENT_AFTER_SEND, static function ($event) use ($path) {
+ if (is_file($path)) {
+ @unlink($path);
+ }
+ });
+ }
+
+ public function actionCreateForm(string $product_guid): string
+ {
+ $product = Products1cNomenclature::findOne(['id' => $product_guid]);
+ if (!$product) {
+ throw new NotFoundHttpException('Товар не найден.');
+ }
+
+ $model = new ProductMapping();
+ $model->product_guid = $product_guid;
+ $model->quant = 1;
+
+ return $this->renderAjax('/product-mapping/_form', [
+ 'model' => $model,
+ 'product' => $product,
+ 'suppliers' => $this->getActiveSuppliers(),
+ 'plantations' => $this->getActivePlantations(),
+ 'markings' => $this->getActiveMarkings(),
+ 'selectedMarkingIds' => [],
+ ]);
+ }
+
+ public function actionCreate(): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = new ProductMapping();
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $markingIds = $this->extractMarkingIds();
+ $model->syncMarkings($markingIds);
+
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Маппинг создан']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка создания маппинга: ' . $e->getMessage(), 'product-mapping');
+ return $this->asJson(['success' => false, 'errors' => ['supplier_product_name' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionUpdateForm(int $id): string
+ {
+ $model = $this->findModel($id);
+ $product = Products1cNomenclature::findOne(['id' => $model->product_guid]);
+
+ return $this->renderAjax('/product-mapping/_form', [
+ 'model' => $model,
+ 'product' => $product,
+ 'suppliers' => $this->getActiveSuppliers(),
+ 'plantations' => $this->getActivePlantations(),
+ 'markings' => $this->getActiveMarkings(),
+ 'selectedMarkingIds' => $model->getCurrentMarkingIds(),
+ ]);
+ }
+
+ public function actionUpdate(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ if ($model->load(Yii::$app->request->post()) && $model->save()) {
+ $markingIds = $this->extractMarkingIds();
+ $model->syncMarkings($markingIds);
+
+ $transaction->commit();
+ return $this->asJson(['success' => true, 'message' => 'Маппинг обновлён']);
+ }
+ $transaction->rollBack();
+ return $this->asJson(['success' => false, 'errors' => $model->errors]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка обновления маппинга: ' . $e->getMessage(), 'product-mapping');
+ return $this->asJson(['success' => false, 'errors' => ['supplier_product_name' => [$e->getMessage()]]]);
+ }
+ }
+
+ public function actionDelete(int $id): Response
+ {
+ Yii::$app->response->format = Response::FORMAT_JSON;
+
+ $model = $this->findModel($id);
+ $transaction = Yii::$app->db->beginTransaction();
+
+ try {
+ // mapping_markings удаляются каскадно (ON DELETE CASCADE)
+ $model->delete();
+ $transaction->commit();
+
+ return $this->asJson([
+ 'success' => true,
+ 'message' => 'Маппинг удалён.',
+ ]);
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ Yii::error('Ошибка удаления маппинга: ' . $e->getMessage(), 'product-mapping');
+ return $this->asJson(['success' => false, 'message' => 'Ошибка удаления: ' . $e->getMessage()]);
+ }
+ }
+
+ /* =====================================================
+ * HELPERS
+ * ===================================================== */
+
+ protected function findModel(int $id): ProductMapping
+ {
+ if (($model = ProductMapping::findOne(['id' => $id])) !== null) {
+ return $model;
+ }
+ throw new NotFoundHttpException('Маппинг не найден.');
+ }
+
+ /** @return int[] */
+ protected function extractMarkingIds(): array
+ {
+ $post = Yii::$app->request->post('ProductMapping', []);
+ $ids = $post['marking_ids'] ?? [];
+ if (!is_array($ids)) {
+ return [];
+ }
+ return array_values(array_filter(array_map('intval', $ids)));
+ }
+
+ /** @return array<int,string> id => name */
+ protected function getActiveSuppliers(): array
+ {
+ return Supplier::findActive()
+ ->orderBy(['name' => SORT_ASC])
+ ->select(['name', 'id'])
+ ->indexBy('id')
+ ->column();
+ }
+
+ /**
+ * @return array<int,array{id:int,label:string}> — плантации с форматом "Название (Страна) — Производитель"
+ */
+ protected function getActivePlantations(): array
+ {
+ $rows = Plantation::find()
+ ->alias('pl')
+ ->innerJoin(['pr' => '{{%erp24.producers}}'], 'pr.id = pl.producer_id')
+ ->where(['pl.is_active' => true, 'pr.is_active' => true])
+ ->orderBy(['pr.name' => SORT_ASC, 'pl.name' => SORT_ASC])
+ ->select([
+ 'id' => 'pl.id',
+ 'name' => 'pl.name',
+ 'country' => 'pl.country',
+ 'producer_name' => 'pr.name',
+ ])
+ ->asArray()
+ ->all();
+
+ $result = [];
+ foreach ($rows as $row) {
+ $result[(int)$row['id']] = sprintf(
+ '%s (%s) — %s',
+ $row['name'],
+ $row['country'],
+ $row['producer_name']
+ );
+ }
+ return $result;
+ }
+
+ /**
+ * @return array<int,string> id => "CODE — product_name (producer / plantation)"
+ */
+ protected function getActiveMarkings(): array
+ {
+ /** @var Marking[] $markings */
+ $markings = Marking::find()
+ ->where(['is_active' => true])
+ ->with(['producer', 'plantation'])
+ ->orderBy(['code' => SORT_ASC])
+ ->all();
+
+ $result = [];
+ foreach ($markings as $m) {
+ $result[(int)$m->id] = sprintf(
+ '%s — %s (%s / %s)',
+ $m->code,
+ $m->product_name,
+ $m->producer->name ?? '—',
+ $m->plantation->name ?? '—'
+ );
+ }
+ return $result;
+ }
+}
$dataProvider = $searchModel->search(Yii::$app->request->queryParams);
if (Yii::$app->request->isAjax) {
- return $this->renderPartial('/supplier/index', [
+ return $this->renderAjax('/supplier/index', [
'searchModel' => $searchModel,
'dataProvider' => $dataProvider,
]);
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\forms;
+
+use yii\base\Model;
+use yii_app\records\Marking;
+use yii_app\records\Supplier;
+
+/**
+ * Форма фильтров для маппинга товаров.
+ * Whitelist полей — защита от прокидывания произвольных параметров в SQL.
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-322
+ */
+class ProductMappingFilterForm extends Model
+{
+ public ?string $category = null;
+ public ?string $subcategory = null;
+ public ?string $species = null;
+ public ?string $folder_1c = null;
+ public ?int $supplier_id = null;
+ public ?int $marking_id = null;
+
+ /** null = без фильтра; true = только актуальные; false = только неактуальные */
+ public ?bool $is_actual = null;
+
+ /** true = показывать только товары без единого маппинга */
+ public bool $only_without_supplier = false;
+
+ public ?string $search = null;
+
+ public function rules(): array
+ {
+ return [
+ [['category', 'subcategory', 'species', 'folder_1c', 'search'], 'string', 'max' => 200],
+ [['supplier_id', 'marking_id'], 'integer'],
+ [['is_actual', 'only_without_supplier'], 'boolean'],
+ [
+ 'supplier_id',
+ 'exist',
+ 'targetClass' => Supplier::class,
+ 'targetAttribute' => 'id',
+ 'skipOnEmpty' => true,
+ ],
+ [
+ 'marking_id',
+ 'exist',
+ 'targetClass' => Marking::class,
+ 'targetAttribute' => 'id',
+ 'skipOnEmpty' => true,
+ ],
+ [['category', 'subcategory', 'species', 'folder_1c', 'search'], 'default', 'value' => null],
+ [['supplier_id', 'marking_id', 'is_actual'], 'default', 'value' => null],
+ ['only_without_supplier', 'default', 'value' => false],
+ ];
+ }
+
+ /**
+ * Нормализовать и загрузить из массива.
+ * Пустые строки превращаем в null.
+ * Приводим значения к объявленным типам свойств (whitelist).
+ *
+ * @param array<string,mixed> $data
+ */
+ public function loadFilters(array $data): bool
+ {
+ $typeMap = [
+ 'category' => 'string',
+ 'subcategory' => 'string',
+ 'species' => 'string',
+ 'folder_1c' => 'string',
+ 'search' => 'string',
+ 'supplier_id' => 'int',
+ 'marking_id' => 'int',
+ 'is_actual' => 'bool',
+ 'only_without_supplier' => 'bool',
+ ];
+
+ foreach ($data as $key => $value) {
+ if (!isset($typeMap[$key])) {
+ continue;
+ }
+ if ($value === '' || $value === null) {
+ // Для non-nullable bool свойства ставим false
+ if ($key === 'only_without_supplier') {
+ $this->only_without_supplier = false;
+ } else {
+ $this->$key = null;
+ }
+ continue;
+ }
+
+ switch ($typeMap[$key]) {
+ case 'string':
+ $this->$key = (string)$value;
+ break;
+ case 'int':
+ $this->$key = (int)$value;
+ break;
+ case 'bool':
+ $bool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ if ($key === 'only_without_supplier') {
+ $this->only_without_supplier = (bool)$bool;
+ } else {
+ $this->$key = $bool;
+ }
+ break;
+ }
+ }
+ return $this->validate();
+ }
+
+ public function formName(): string
+ {
+ return '';
+ }
+
+ /**
+ * Активные (не-null, не-false-дефолты) фильтры.
+ * Не переопределяем Model::toArray() — другая сигнатура.
+ *
+ * @return array<string,mixed>
+ */
+ public function getActiveFilters(): array
+ {
+ $result = [];
+ foreach (['category', 'subcategory', 'species', 'folder_1c', 'supplier_id', 'marking_id', 'search'] as $k) {
+ if ($this->$k !== null && $this->$k !== '') {
+ $result[$k] = $this->$k;
+ }
+ }
+ if ($this->is_actual !== null) {
+ $result['is_actual'] = (bool)$this->is_actual;
+ }
+ if ($this->only_without_supplier) {
+ $result['only_without_supplier'] = true;
+ }
+ return $result;
+ }
+
+ /**
+ * Нет ли ни одного установленного фильтра.
+ */
+ public function isEmpty(): bool
+ {
+ return empty($this->getActiveFilters());
+ }
+}
{
public function safeUp()
{
+ if (Yii::$app->db->getTableSchema('erp24.suppliers') !== null) {
+ echo "Таблица suppliers уже существует, пропускаем.\n";
+ return true;
+ }
+
$this->createTable('{{%erp24.suppliers}}', [
'id' => $this->primaryKey(),
'name' => $this->string(200)->notNull()->unique()->comment('Название поставщика'),
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Справочник производителей (селекционеров/брендов).
+ * Epic: ERP-300, Story: ERP-317
+ */
+class m260408_100100_create_producers_table extends Migration
+{
+ public function safeUp()
+ {
+ if (Yii::$app->db->getTableSchema('erp24.producers') !== null) {
+ echo "Таблица producers уже существует, пропускаем.\n";
+ return true;
+ }
+
+ $this->createTable('{{%erp24.producers}}', [
+ 'id' => $this->primaryKey(),
+ 'name' => $this->string(200)->notNull()->unique()->comment('Название производителя'),
+ 'is_active' => $this->boolean()->notNull()->defaultValue(true)->comment('Активен (soft delete)'),
+ 'created_by' => $this->integer()->notNull()->comment('Кто создал (admin.id)'),
+ 'updated_by' => $this->integer()->null()->comment('Кто обновил (admin.id)'),
+ 'created_at' => $this->timestamp()->notNull()->comment('Дата создания'),
+ 'updated_at' => $this->timestamp()->null()->comment('Дата обновления'),
+ ]);
+ }
+
+ public function safeDown()
+ {
+ $this->dropTable('{{%erp24.producers}}');
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Справочник плантаций (ферм производителей).
+ * Epic: ERP-300, Story: ERP-317
+ */
+class m260408_100200_create_plantations_table extends Migration
+{
+ public function safeUp()
+ {
+ if (Yii::$app->db->getTableSchema('erp24.plantations') !== null) {
+ echo "Таблица plantations уже существует, пропускаем.\n";
+ return true;
+ }
+
+ $this->createTable('{{%erp24.plantations}}', [
+ 'id' => $this->primaryKey(),
+ 'name' => $this->string(200)->notNull()->comment('Название плантации'),
+ 'producer_id' => $this->integer()->notNull()->comment('Производитель (producers.id)'),
+ 'country' => $this->string(100)->notNull()->comment('Страна'),
+ 'is_active' => $this->boolean()->notNull()->defaultValue(true)->comment('Активна (soft delete)'),
+ 'created_by' => $this->integer()->notNull()->comment('Кто создал (admin.id)'),
+ 'updated_by' => $this->integer()->null()->comment('Кто обновил (admin.id)'),
+ 'created_at' => $this->timestamp()->notNull()->comment('Дата создания'),
+ 'updated_at' => $this->timestamp()->null()->comment('Дата обновления'),
+ ]);
+
+ $this->addForeignKey(
+ 'fk_plantations_producer',
+ '{{%erp24.plantations}}',
+ 'producer_id',
+ '{{%erp24.producers}}',
+ 'id',
+ 'RESTRICT',
+ 'CASCADE'
+ );
+
+ $this->createIndex(
+ 'idx_plantations_producer_id',
+ '{{%erp24.plantations}}',
+ 'producer_id'
+ );
+
+ $this->createIndex(
+ 'uq_plantations_producer_name',
+ '{{%erp24.plantations}}',
+ ['producer_id', 'name'],
+ true
+ );
+ }
+
+ public function safeDown()
+ {
+ $this->dropTable('{{%erp24.plantations}}');
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Справочник маркировок (коды производителей/поставщиков).
+ * Epic: ERP-300, Story: ERP-317
+ */
+class m260408_100300_create_markings_table extends Migration
+{
+ public function safeUp()
+ {
+ if (Yii::$app->db->getTableSchema('erp24.markings') !== null) {
+ echo "Таблица markings уже существует, пропускаем.\n";
+ return true;
+ }
+
+ $this->createTable('{{%erp24.markings}}', [
+ 'id' => $this->primaryKey(),
+ 'code' => $this->string(50)->notNull()->unique()->comment('Код маркировки (SCH-RP-RN50)'),
+ 'producer_id' => $this->integer()->notNull()->comment('Производитель (producers.id)'),
+ 'plantation_id' => $this->integer()->notNull()->comment('Плантация (plantations.id)'),
+ 'product_name' => $this->string(200)->notNull()->comment('Название товара у производителя'),
+ 'supplier_id' => $this->integer()->notNull()->comment('Поставщик (suppliers.id)'),
+ 'is_active' => $this->boolean()->notNull()->defaultValue(true)->comment('Активна'),
+ 'created_by' => $this->integer()->notNull()->comment('Кто создал (admin.id)'),
+ 'updated_by' => $this->integer()->null()->comment('Кто обновил (admin.id)'),
+ 'created_at' => $this->timestamp()->notNull()->comment('Дата создания'),
+ 'updated_at' => $this->timestamp()->null()->comment('Дата обновления'),
+ ]);
+
+ $this->addForeignKey('fk_markings_producer', '{{%erp24.markings}}', 'producer_id', '{{%erp24.producers}}', 'id', 'RESTRICT', 'CASCADE');
+ $this->addForeignKey('fk_markings_plantation', '{{%erp24.markings}}', 'plantation_id', '{{%erp24.plantations}}', 'id', 'RESTRICT', 'CASCADE');
+ $this->addForeignKey('fk_markings_supplier', '{{%erp24.markings}}', 'supplier_id', '{{%erp24.suppliers}}', 'id', 'RESTRICT', 'CASCADE');
+
+ $this->createIndex('idx_markings_supplier_id', '{{%erp24.markings}}', 'supplier_id');
+ $this->createIndex('idx_markings_producer_id', '{{%erp24.markings}}', 'producer_id');
+ $this->createIndex('uq_markings_combo', '{{%erp24.markings}}', ['producer_id', 'plantation_id', 'product_name'], true);
+ }
+
+ public function safeDown()
+ {
+ $this->dropTable('{{%erp24.markings}}');
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Маппинг товаров 1С к поставщикам.
+ * product_guid — soft FK на products_1c.id (VARCHAR GUID из 1С).
+ * Partial UNIQUE indexes для nullable plantation_id (PostgreSQL).
+ * Epic: ERP-300, Story: ERP-317
+ */
+class m260408_100400_create_product_mappings_table extends Migration
+{
+ public function safeUp()
+ {
+ if (Yii::$app->db->getTableSchema('erp24.product_mappings') !== null) {
+ echo "Таблица product_mappings уже существует, пропускаем.\n";
+ return true;
+ }
+
+ $this->createTable('{{%erp24.product_mappings}}', [
+ 'id' => $this->primaryKey(),
+ 'product_guid' => $this->string(36)->notNull()->comment('GUID товара (products_1c.id)'),
+ 'supplier_id' => $this->integer()->notNull()->comment('Поставщик (suppliers.id)'),
+ 'plantation_id' => $this->integer()->null()->comment('Плантация (plantations.id)'),
+ 'supplier_product_name' => $this->string(500)->notNull()->comment('Название товара у поставщика'),
+ 'article' => $this->string(100)->null()->comment('Артикул поставщика'),
+ 'barcode' => $this->string(50)->null()->comment('Штрихкод EAN-13'),
+ 'quant' => $this->integer()->notNull()->defaultValue(1)->comment('Кратность заказа'),
+ 'created_by' => $this->integer()->notNull()->comment('Кто создал (admin.id)'),
+ 'updated_by' => $this->integer()->null()->comment('Кто обновил (admin.id)'),
+ 'created_at' => $this->timestamp()->notNull()->comment('Дата создания'),
+ 'updated_at' => $this->timestamp()->null()->comment('Дата обновления'),
+ ]);
+
+ $this->addForeignKey('fk_pm_supplier', '{{%erp24.product_mappings}}', 'supplier_id', '{{%erp24.suppliers}}', 'id', 'RESTRICT', 'CASCADE');
+ $this->addForeignKey('fk_pm_plantation', '{{%erp24.product_mappings}}', 'plantation_id', '{{%erp24.plantations}}', 'id', 'SET NULL', 'CASCADE');
+
+ $this->execute("ALTER TABLE {{%erp24.product_mappings}} ADD CONSTRAINT chk_pm_quant CHECK (quant >= 1)");
+
+ // Partial UNIQUE indexes для nullable plantation_id (PostgreSQL-specific)
+ $this->execute("CREATE UNIQUE INDEX uq_pm_product_supplier ON {{%erp24.product_mappings}}(product_guid, supplier_id) WHERE plantation_id IS NULL");
+ $this->execute("CREATE UNIQUE INDEX uq_pm_product_supplier_plantation ON {{%erp24.product_mappings}}(product_guid, supplier_id, plantation_id) WHERE plantation_id IS NOT NULL");
+
+ $this->createIndex('idx_pm_product_guid', '{{%erp24.product_mappings}}', 'product_guid');
+ $this->createIndex('idx_pm_supplier_id', '{{%erp24.product_mappings}}', 'supplier_id');
+ $this->createIndex('idx_pm_plantation_id', '{{%erp24.product_mappings}}', 'plantation_id');
+
+ // Partial index на barcode (только NOT NULL)
+ $this->execute("CREATE INDEX idx_pm_barcode ON {{%erp24.product_mappings}}(barcode) WHERE barcode IS NOT NULL");
+ }
+
+ public function safeDown()
+ {
+ $this->dropTable('{{%erp24.product_mappings}}');
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Junction table: маппинг ↔ маркировки (N:M).
+ * CASCADE на обе стороны.
+ * Epic: ERP-300, Story: ERP-317
+ */
+class m260408_100500_create_mapping_markings_table extends Migration
+{
+ public function safeUp()
+ {
+ if (Yii::$app->db->getTableSchema('erp24.mapping_markings') !== null) {
+ echo "Таблица mapping_markings уже существует, пропускаем.\n";
+ return true;
+ }
+
+ $this->createTable('{{%erp24.mapping_markings}}', [
+ 'mapping_id' => $this->integer()->notNull()->comment('Маппинг (product_mappings.id)'),
+ 'marking_id' => $this->integer()->notNull()->comment('Маркировка (markings.id)'),
+ ]);
+
+ $this->addPrimaryKey('pk_mapping_markings', '{{%erp24.mapping_markings}}', ['mapping_id', 'marking_id']);
+
+ $this->addForeignKey('fk_mm_mapping', '{{%erp24.mapping_markings}}', 'mapping_id', '{{%erp24.product_mappings}}', 'id', 'CASCADE', 'CASCADE');
+ $this->addForeignKey('fk_mm_marking', '{{%erp24.mapping_markings}}', 'marking_id', '{{%erp24.markings}}', 'id', 'CASCADE', 'CASCADE');
+
+ $this->createIndex('idx_mm_marking_id', '{{%erp24.mapping_markings}}', 'marking_id');
+ }
+
+ public function safeDown()
+ {
+ $this->dropTable('{{%erp24.mapping_markings}}');
+ }
+}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Добавить RBAC permissions для справочника закупщика (ERP-300).
+ *
+ * Добавляет записи в admin_group_rbac_config для групп IT (81) и Director (1)
+ * со всеми permissions контроллеров /buyer-reference, /supplier, /producer.
+ *
+ * После миграции ОБЯЗАТЕЛЬНО запустить:
+ * php yii auth/init
+ * чтобы permissions создались в auth_item и назначились пользователям.
+ *
+ * Epic: ERP-300
+ * Story: ERP-319
+ */
+class m260409_120000_add_erp300_rbac_permissions extends Migration
+{
+ /** Группы, которым даём доступ */
+ private const TARGET_GROUP_IDS = [
+ 81, // GROUP_IT
+ 1, // DIRECTOR
+ ];
+
+ /** Новые permissions (формат auth_item.name) */
+ private const PERMISSIONS = [
+ // Главная страница справочника
+ 'menu/buyer-reference/index',
+
+ // Поставщики (ERP-318)
+ 'menu/supplier/index',
+ 'menu/supplier/create',
+ 'menu/supplier/create-form',
+ 'menu/supplier/update',
+ 'menu/supplier/update-form',
+ 'menu/supplier/delete',
+
+ // Производители и плантации (ERP-319)
+ 'menu/producer/index',
+ 'menu/producer/create-producer',
+ 'menu/producer/create-producer-form',
+ 'menu/producer/update-producer',
+ 'menu/producer/update-producer-form',
+ 'menu/producer/delete-producer',
+ 'menu/producer/create-plantation',
+ 'menu/producer/create-plantation-form',
+ 'menu/producer/update-plantation',
+ 'menu/producer/update-plantation-form',
+ 'menu/producer/delete-plantation',
+
+ // Маркировки (ERP-320)
+ 'menu/marking/index',
+ 'menu/marking/create',
+ 'menu/marking/create-form',
+ 'menu/marking/update',
+ 'menu/marking/update-form',
+ 'menu/marking/delete',
+ 'menu/marking/plantations-by-producer',
+
+ // Маппинг товаров (ERP-321)
+ 'menu/product-mapping/index',
+ 'menu/product-mapping/create-form',
+ 'menu/product-mapping/create',
+ 'menu/product-mapping/update-form',
+ 'menu/product-mapping/update',
+ 'menu/product-mapping/delete',
+
+ // Фильтры + аналитика маппинга (ERP-322)
+ 'menu/product-mapping/analytics',
+ 'menu/product-mapping/cascade-filters',
+
+ // Экспорт маппинга (ERP-323)
+ 'menu/product-mapping/export',
+ ];
+
+ public function safeUp()
+ {
+ foreach (self::TARGET_GROUP_IDS as $groupId) {
+ $row = (new \yii\db\Query())
+ ->from('admin_group_rbac_config')
+ ->where(['admin_group_id' => $groupId])
+ ->one();
+
+ if ($row !== false && $row !== null) {
+ $existing = array_filter(explode(',', (string)$row['config']));
+ } else {
+ $existing = [];
+ }
+
+ $merged = array_values(array_unique(array_merge($existing, self::PERMISSIONS)));
+ $configStr = implode(',', $merged);
+
+ if ($row !== false && $row !== null) {
+ $this->update(
+ 'admin_group_rbac_config',
+ ['config' => $configStr],
+ ['admin_group_id' => $groupId]
+ );
+ echo " Обновлена группа {$groupId}: добавлено " . count(self::PERMISSIONS) . " permissions\n";
+ } else {
+ $this->insert('admin_group_rbac_config', [
+ 'admin_group_id' => $groupId,
+ 'config' => $configStr,
+ ]);
+ echo " Создана запись для группы {$groupId} с " . count(self::PERMISSIONS) . " permissions\n";
+ }
+ }
+
+ // Сбросить флаг, чтобы auth/init пере-применил изменения
+ Yii::$app->cache->set('dirtyAuthSettings', true);
+
+ echo "\n";
+ echo "==============================================================\n";
+ echo " ВАЖНО: теперь запустите следующую команду:\n";
+ echo " php yii auth/init\n";
+ echo " чтобы permissions создались в auth_item и auth_assignment.\n";
+ echo "==============================================================\n";
+ }
+
+ public function safeDown()
+ {
+ foreach (self::TARGET_GROUP_IDS as $groupId) {
+ $row = (new \yii\db\Query())
+ ->from('admin_group_rbac_config')
+ ->where(['admin_group_id' => $groupId])
+ ->one();
+
+ if ($row === false || $row === null) {
+ continue;
+ }
+
+ $existing = array_filter(explode(',', (string)$row['config']));
+ $filtered = array_values(array_diff($existing, self::PERMISSIONS));
+
+ $this->update(
+ 'admin_group_rbac_config',
+ ['config' => implode(',', $filtered)],
+ ['admin_group_id' => $groupId]
+ );
+ }
+
+ Yii::$app->cache->set('dirtyAuthSettings', true);
+
+ echo "\nПосле отката запустите: php yii auth/init\n";
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\db\ActiveRecord;
+
+/**
+ * Junction table: маппинг ↔ маркировки (N:M).
+ *
+ * @property int $mapping_id
+ * @property int $marking_id
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-321
+ */
+class MappingMarking extends ActiveRecord
+{
+ public static function tableName(): string
+ {
+ return '{{%erp24.mapping_markings}}';
+ }
+
+ public static function primaryKey(): array
+ {
+ return ['mapping_id', 'marking_id'];
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['mapping_id', 'marking_id'], 'required'],
+ [['mapping_id', 'marking_id'], 'integer'],
+ ];
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\base\Model;
+use yii\data\ActiveDataProvider;
+
+/**
+ * Search модель для справочника маркировок.
+ * Eager load: producer, plantation, supplier.
+ */
+class MarkingSearch extends Model
+{
+ public $id;
+ public $code;
+ public $producer_id;
+ public $plantation_id;
+ public $supplier_id;
+ public $product_name;
+ public $is_active;
+
+ public function rules(): array
+ {
+ return [
+ [['id', 'producer_id', 'plantation_id', 'supplier_id'], 'integer'],
+ [['code', 'product_name'], 'safe'],
+ [['is_active'], 'boolean'],
+ ];
+ }
+
+ public function search(array $params): ActiveDataProvider
+ {
+ $query = Marking::find()
+ ->with(['producer', 'plantation', 'supplier'])
+ ->orderBy(['code' => SORT_ASC]);
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'pagination' => false,
+ ]);
+
+ $this->load($params);
+
+ if (!$this->validate()) {
+ return $dataProvider;
+ }
+
+ $query->andFilterWhere(['id' => $this->id])
+ ->andFilterWhere(['producer_id' => $this->producer_id])
+ ->andFilterWhere(['plantation_id' => $this->plantation_id])
+ ->andFilterWhere(['supplier_id' => $this->supplier_id])
+ ->andFilterWhere(['is_active' => $this->is_active])
+ ->andFilterWhere(['like', 'code', $this->code])
+ ->andFilterWhere(['like', 'product_name', $this->product_name]);
+
+ return $dataProvider;
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+
+/**
+ * Справочник плантаций (ферм производителей).
+ *
+ * @property int $id
+ * @property string $name Название плантации
+ * @property int $producer_id Производитель (producers.id)
+ * @property string $country Страна
+ * @property bool $is_active Активна (soft delete)
+ * @property int $created_by Кто создал
+ * @property int|null $updated_by Кто обновил
+ * @property string $created_at Дата создания
+ * @property string|null $updated_at Дата обновления
+ *
+ * @property-read Producer $producer
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-319
+ */
+class Plantation extends ActiveRecord
+{
+ public static function tableName(): string
+ {
+ return '{{%erp24.plantations}}';
+ }
+
+ public function behaviors(): array
+ {
+ return [
+ [
+ 'class' => TimestampBehavior::class,
+ 'createdAtAttribute' => 'created_at',
+ 'updatedAtAttribute' => 'updated_at',
+ 'value' => new Expression('NOW()'),
+ ],
+ [
+ 'class' => BlameableBehavior::class,
+ 'createdByAttribute' => 'created_by',
+ 'updatedByAttribute' => 'updated_by',
+ 'defaultValue' => null,
+ ],
+ ];
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['name', 'producer_id', 'country'], 'required'],
+ ['name', 'string', 'max' => 200],
+ ['country', 'string', 'max' => 100],
+ ['country', 'in', 'range' => array_keys(self::getCountryOptions())],
+ ['producer_id', 'integer'],
+ [
+ 'producer_id',
+ 'exist',
+ 'targetClass' => Producer::class,
+ 'targetAttribute' => 'id',
+ 'message' => 'Производитель не найден',
+ ],
+ [
+ ['name'],
+ 'unique',
+ 'targetAttribute' => ['producer_id', 'name'],
+ 'message' => 'Плантация с таким названием уже существует у этого производителя',
+ ],
+ ['is_active', 'boolean'],
+ ['is_active', 'default', 'value' => true],
+ ];
+ }
+
+ public function attributeLabels(): array
+ {
+ return [
+ 'id' => 'ID',
+ 'name' => 'Название',
+ 'producer_id' => 'Производитель',
+ 'country' => 'Страна',
+ 'is_active' => 'Статус',
+ 'created_by' => 'Создал',
+ 'updated_by' => 'Обновил',
+ 'created_at' => 'Создано',
+ 'updated_at' => 'Обновлено',
+ ];
+ }
+
+ /* --- Relations --- */
+
+ public function getProducer(): ActiveQuery
+ {
+ return $this->hasOne(Producer::class, ['id' => 'producer_id']);
+ }
+
+ /* --- Справочник стран --- */
+
+ /**
+ * Статический список стран-производителей цветов.
+ * При необходимости расширить.
+ */
+ public static function getCountryOptions(): array
+ {
+ return [
+ 'Эквадор' => 'Эквадор',
+ 'Колумбия' => 'Колумбия',
+ 'Кения' => 'Кения',
+ 'Эфиопия' => 'Эфиопия',
+ 'Нидерланды' => 'Нидерланды',
+ 'Россия' => 'Россия',
+ 'Турция' => 'Турция',
+ 'Израиль' => 'Израиль',
+ 'Италия' => 'Италия',
+ ];
+ }
+
+ /* --- Бейджи --- */
+
+ public function getStatusBadge(): string
+ {
+ if ($this->is_active) {
+ return '<span class="badge-status-active">Активна</span>';
+ }
+ return '<span class="badge-status-inactive">Неактивна</span>';
+ }
+
+ /* --- Блокировки --- */
+
+ /**
+ * Есть ли активные маркировки, связанные с этой плантацией.
+ */
+ public function hasActiveMarkings(): bool
+ {
+ return (bool)Yii::$app->db->createCommand(
+ 'SELECT 1 FROM {{%erp24.markings}} WHERE plantation_id = :pid AND is_active = TRUE LIMIT 1',
+ [':pid' => $this->id]
+ )->queryScalar();
+ }
+
+ /* --- Деактивация --- */
+
+ /**
+ * Деактивация плантации. Блокируется при активных маркировках.
+ *
+ * @throws \RuntimeException если есть активные маркировки
+ */
+ public function deactivate(): void
+ {
+ if ($this->hasActiveMarkings()) {
+ throw new \RuntimeException(
+ 'Нельзя деактивировать плантацию: есть активные маркировки. Сначала деактивируйте их.'
+ );
+ }
+
+ $this->is_active = false;
+ $this->save(false);
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+
+/**
+ * Справочник производителей (селекционеров/брендов).
+ *
+ * @property int $id
+ * @property string $name Название производителя
+ * @property bool $is_active Активен (soft delete)
+ * @property int $created_by Кто создал
+ * @property int|null $updated_by Кто обновил
+ * @property string $created_at Дата создания
+ * @property string|null $updated_at Дата обновления
+ *
+ * @property-read Plantation[] $plantations
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-319
+ */
+class Producer extends ActiveRecord
+{
+ public static function tableName(): string
+ {
+ return '{{%erp24.producers}}';
+ }
+
+ public function behaviors(): array
+ {
+ return [
+ [
+ 'class' => TimestampBehavior::class,
+ 'createdAtAttribute' => 'created_at',
+ 'updatedAtAttribute' => 'updated_at',
+ 'value' => new Expression('NOW()'),
+ ],
+ [
+ 'class' => BlameableBehavior::class,
+ 'createdByAttribute' => 'created_by',
+ 'updatedByAttribute' => 'updated_by',
+ 'defaultValue' => null,
+ ],
+ ];
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['name'], 'required'],
+ ['name', 'string', 'max' => 200],
+ ['name', 'unique', 'message' => 'Производитель с таким названием уже существует'],
+ ['is_active', 'boolean'],
+ ['is_active', 'default', 'value' => true],
+ ];
+ }
+
+ public function attributeLabels(): array
+ {
+ return [
+ 'id' => 'ID',
+ 'name' => 'Название',
+ 'is_active' => 'Статус',
+ 'created_by' => 'Создал',
+ 'updated_by' => 'Обновил',
+ 'created_at' => 'Создано',
+ 'updated_at' => 'Обновлено',
+ ];
+ }
+
+ /* --- Scopes --- */
+
+ public static function findActive(): ActiveQuery
+ {
+ return static::find()->where(['is_active' => true]);
+ }
+
+ /* --- Relations --- */
+
+ public function getPlantations(): ActiveQuery
+ {
+ return $this->hasMany(Plantation::class, ['producer_id' => 'id']);
+ }
+
+ public function getActivePlantations(): ActiveQuery
+ {
+ return $this->getPlantations()->andWhere(['is_active' => true]);
+ }
+
+ /* --- Бейджи --- */
+
+ public function getStatusBadge(): string
+ {
+ if ($this->is_active) {
+ return '<span class="badge-status-active">Активен</span>';
+ }
+ return '<span class="badge-status-inactive">Неактивен</span>';
+ }
+
+ /* --- Блокировки --- */
+
+ /**
+ * Есть ли активные маркировки, связанные с этим производителем
+ * (прямо через producer_id).
+ */
+ public function hasActiveMarkings(): bool
+ {
+ return (bool)Yii::$app->db->createCommand(
+ 'SELECT 1 FROM {{%erp24.markings}} WHERE producer_id = :pid AND is_active = TRUE LIMIT 1',
+ [':pid' => $this->id]
+ )->queryScalar();
+ }
+
+ /* --- Каскадная деактивация --- */
+
+ /**
+ * Деактивация производителя и всех его плантаций в транзакции.
+ * Блокируется при наличии активных маркировок.
+ *
+ * @return array{plantations: int}
+ * @throws \RuntimeException если есть активные маркировки
+ */
+ public function deactivate(): array
+ {
+ if ($this->hasActiveMarkings()) {
+ throw new \RuntimeException(
+ 'Нельзя деактивировать производителя: есть активные маркировки. Сначала деактивируйте их.'
+ );
+ }
+
+ $transaction = Yii::$app->db->beginTransaction();
+ try {
+ $this->is_active = false;
+ $this->save(false);
+
+ $plantationsCount = (int)Yii::$app->db->createCommand()
+ ->update(
+ '{{%erp24.plantations}}',
+ ['is_active' => false],
+ ['producer_id' => $this->id, 'is_active' => true]
+ )
+ ->execute();
+
+ $transaction->commit();
+
+ return ['plantations' => $plantationsCount];
+ } catch (\Exception $e) {
+ $transaction->rollBack();
+ throw $e;
+ }
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use yii\base\Model;
+use yii\data\ActiveDataProvider;
+
+/**
+ * Search модель для справочника производителей.
+ * Использует eager loading для плантаций.
+ */
+class ProducerSearch extends Model
+{
+ public $id;
+ public $name;
+ public $is_active;
+
+ public function rules(): array
+ {
+ return [
+ [['id'], 'integer'],
+ [['name'], 'safe'],
+ [['is_active'], 'boolean'],
+ ];
+ }
+
+ public function search(array $params): ActiveDataProvider
+ {
+ $query = Producer::find()
+ ->with(['plantations' => function ($q) {
+ $q->orderBy(['name' => SORT_ASC]);
+ }])
+ ->orderBy(['name' => SORT_ASC]);
+
+ $dataProvider = new ActiveDataProvider([
+ 'query' => $query,
+ 'pagination' => false,
+ ]);
+
+ $this->load($params);
+
+ if (!$this->validate()) {
+ return $dataProvider;
+ }
+
+ $query->andFilterWhere(['id' => $this->id])
+ ->andFilterWhere(['is_active' => $this->is_active])
+ ->andFilterWhere(['like', 'name', $this->name]);
+
+ return $dataProvider;
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\BlameableBehavior;
+use yii\behaviors\TimestampBehavior;
+use yii\db\ActiveQuery;
+use yii\db\ActiveRecord;
+use yii\db\Expression;
+
+/**
+ * Маппинг товара 1С на поставщика.
+ * Soft FK на products_1c.id через product_guid (VARCHAR(36)).
+ *
+ * @property int $id
+ * @property string $product_guid GUID товара (products_1c.id)
+ * @property int $supplier_id
+ * @property int|null $plantation_id
+ * @property string $supplier_product_name
+ * @property string|null $article
+ * @property string|null $barcode
+ * @property int $quant Кратность заказа (>=1)
+ * @property int $created_by
+ * @property int|null $updated_by
+ * @property string $created_at
+ * @property string|null $updated_at
+ *
+ * @property-read Supplier $supplier
+ * @property-read Plantation|null $plantation
+ * @property-read Marking[] $markings
+ * @property-read Products1c|null $product
+ * @property-read Products1cNomenclature|null $productNomenclature
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-321
+ */
+class ProductMapping extends ActiveRecord
+{
+ /**
+ * Виртуальное поле: ID выбранных маркировок (из формы).
+ *
+ * @var int[]
+ */
+ public array $marking_ids = [];
+
+ public static function tableName(): string
+ {
+ return '{{%erp24.product_mappings}}';
+ }
+
+ public function behaviors(): array
+ {
+ return [
+ [
+ 'class' => TimestampBehavior::class,
+ 'createdAtAttribute' => 'created_at',
+ 'updatedAtAttribute' => 'updated_at',
+ 'value' => new Expression('NOW()'),
+ ],
+ [
+ 'class' => BlameableBehavior::class,
+ 'createdByAttribute' => 'created_by',
+ 'updatedByAttribute' => 'updated_by',
+ 'defaultValue' => null,
+ ],
+ ];
+ }
+
+ public function rules(): array
+ {
+ return [
+ [['product_guid', 'supplier_id', 'supplier_product_name', 'quant'], 'required'],
+ ['product_guid', 'string', 'max' => 36],
+ ['product_guid', 'validateProductGuidExists'],
+ ['supplier_product_name', 'string', 'max' => 500],
+ ['article', 'string', 'max' => 100],
+ ['barcode', 'string', 'max' => 50],
+ ['quant', 'integer', 'min' => 1, 'message' => 'Кратность должна быть >= 1'],
+ ['quant', 'default', 'value' => 1],
+ [['supplier_id', 'plantation_id'], 'integer'],
+ [
+ 'supplier_id',
+ 'exist',
+ 'targetClass' => Supplier::class,
+ 'targetAttribute' => 'id',
+ 'message' => 'Поставщик не найден',
+ ],
+ [
+ 'plantation_id',
+ 'exist',
+ 'targetClass' => Plantation::class,
+ 'targetAttribute' => 'id',
+ 'skipOnEmpty' => true,
+ 'message' => 'Плантация не найдена',
+ ],
+ ['supplier_id', 'validatePartialUnique'],
+ [['article', 'barcode', 'plantation_id'], 'default', 'value' => null],
+ ['marking_ids', 'each', 'rule' => ['integer']],
+ ['marking_ids', 'default', 'value' => []],
+ ];
+ }
+
+ public function attributeLabels(): array
+ {
+ return [
+ 'id' => 'ID',
+ 'product_guid' => 'Товар',
+ 'supplier_id' => 'Поставщик',
+ 'plantation_id' => 'Плантация',
+ 'supplier_product_name' => 'Название у поставщика',
+ 'article' => 'Артикул',
+ 'barcode' => 'Штрихкод',
+ 'quant' => 'Квант',
+ 'marking_ids' => 'Маркировки',
+ ];
+ }
+
+ /* --- Validators --- */
+
+ /**
+ * Проверка существования товара в products_1c.
+ */
+ public function validateProductGuidExists(string $attribute): void
+ {
+ if (empty($this->$attribute)) {
+ return;
+ }
+
+ $exists = Products1c::find()
+ ->where(['id' => $this->$attribute])
+ ->exists();
+
+ if (!$exists) {
+ $this->addError($attribute, 'Товар с таким GUID не найден в 1С');
+ }
+ }
+
+ /**
+ * Partial unique check: учитывает NULL в plantation_id.
+ * Логика соответствует БД-индексам:
+ * - uq_pm_product_supplier WHERE plantation_id IS NULL
+ * - uq_pm_product_supplier_plantation WHERE plantation_id IS NOT NULL
+ */
+ public function validatePartialUnique(string $attribute): void
+ {
+ if (empty($this->product_guid) || empty($this->supplier_id)) {
+ return;
+ }
+
+ $query = self::find()
+ ->where([
+ 'product_guid' => $this->product_guid,
+ 'supplier_id' => $this->supplier_id,
+ ]);
+
+ if ($this->plantation_id === null || $this->plantation_id === '') {
+ $query->andWhere(['plantation_id' => null]);
+ } else {
+ $query->andWhere(['plantation_id' => $this->plantation_id]);
+ }
+
+ if (!$this->isNewRecord) {
+ $query->andWhere(['!=', 'id', $this->id]);
+ }
+
+ if ($query->exists()) {
+ $this->addError(
+ $attribute,
+ 'Маппинг для этой комбинации товар+поставщик'
+ . ($this->plantation_id ? '+плантация' : '')
+ . ' уже существует'
+ );
+ }
+ }
+
+ /* --- Relations --- */
+
+ public function getSupplier(): ActiveQuery
+ {
+ return $this->hasOne(Supplier::class, ['id' => 'supplier_id']);
+ }
+
+ public function getPlantation(): ActiveQuery
+ {
+ return $this->hasOne(Plantation::class, ['id' => 'plantation_id']);
+ }
+
+ public function getMarkings(): ActiveQuery
+ {
+ return $this->hasMany(Marking::class, ['id' => 'marking_id'])
+ ->viaTable('{{%erp24.mapping_markings}}', ['mapping_id' => 'id']);
+ }
+
+ public function getProduct(): ActiveQuery
+ {
+ return $this->hasOne(Products1c::class, ['id' => 'product_guid']);
+ }
+
+ public function getProductNomenclature(): ActiveQuery
+ {
+ return $this->hasOne(Products1cNomenclature::class, ['id' => 'product_guid']);
+ }
+
+ /* --- Junction sync --- */
+
+ /**
+ * Синхронизация маркировок в mapping_markings.
+ * Удаляет записи, отсутствующие в $markingIds, добавляет новые.
+ * ВЫЗЫВАТЬ ВНУТРИ ТРАНЗАКЦИИ.
+ *
+ * @param int[] $markingIds
+ */
+ public function syncMarkings(array $markingIds): void
+ {
+ $markingIds = array_values(array_unique(array_filter(array_map('intval', $markingIds))));
+
+ // Удаляем все текущие связи
+ Yii::$app->db->createCommand()
+ ->delete('{{%erp24.mapping_markings}}', ['mapping_id' => $this->id])
+ ->execute();
+
+ if (empty($markingIds)) {
+ return;
+ }
+
+ // Вставляем новые
+ $rows = array_map(fn($mid) => [$this->id, $mid], $markingIds);
+ Yii::$app->db->createCommand()
+ ->batchInsert('{{%erp24.mapping_markings}}', ['mapping_id', 'marking_id'], $rows)
+ ->execute();
+ }
+
+ /**
+ * Текущие ID маркировок (для заполнения select в форме редактирования).
+ *
+ * @return int[]
+ */
+ public function getCurrentMarkingIds(): array
+ {
+ return array_map(
+ 'intval',
+ (new \yii\db\Query())
+ ->select('marking_id')
+ ->from('{{%erp24.mapping_markings}}')
+ ->where(['mapping_id' => $this->id])
+ ->column()
+ );
+ }
+}
['name', 'unique', 'message' => 'Поставщик уже существует'],
['type', 'in', 'range' => [self::TYPE_LOCAL, self::TYPE_INTERNATIONAL]],
['currency', 'in', 'range' => [self::CURRENCY_RUB, self::CURRENCY_EUR, self::CURRENCY_USD]],
- ['lead_time_days', 'integer', 'min' => 0, 'message' => 'Lead time должен быть >= 0'],
+ ['lead_time_days', 'integer', 'min' => 0, 'message' => 'Срок поставки должен быть >= 0'],
['lead_time_days', 'default', 'value' => 0],
['is_active', 'boolean'],
['is_active', 'default', 'value' => true],
'name' => 'Название',
'type' => 'Тип',
'currency' => 'Валюта',
- 'lead_time_days' => 'Lead time (дн)',
+ 'lead_time_days' => 'Срок поставки (дн)',
'is_active' => 'Статус',
'created_by' => 'Создал',
'updated_by' => 'Обновил',
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services;
+
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Yii;
+use yii\data\Pagination;
+use yii\db\Expression;
+use yii\db\Query;
+use yii_app\forms\ProductMappingFilterForm;
+use yii_app\records\ProductMapping;
+use yii_app\records\Products1cNomenclature;
+
+/**
+ * Сервис маппинга товаров: фильтры, аналитика, каскадные справочники.
+ *
+ * Производительность: аналитика — ОДИН агрегирующий SQL без N+1.
+ *
+ * @see https://itriteil.atlassian.net/browse/ERP-322
+ */
+class ProductMappingService
+{
+ public const CACHE_DURATION = 300; // 5 минут для справочников фильтров
+
+ /**
+ * Получить список товаров с маппингами и фильтрами.
+ *
+ * @return array{
+ * products: Products1cNomenclature[],
+ * mappingsByGuid: array<string,ProductMapping[]>,
+ * pagination: Pagination
+ * }
+ */
+ public function getProductsWithMappings(
+ ProductMappingFilterForm $filters,
+ int $perPage = 50
+ ): array {
+ $query = $this->buildFilteredQuery($filters);
+
+ $pagination = new Pagination([
+ 'totalCount' => (int)(clone $query)->select(new Expression('COUNT(*)'))->scalar(),
+ 'pageSize' => $perPage,
+ 'pageSizeParam' => false,
+ ]);
+
+ /** @var Products1cNomenclature[] $products */
+ $products = $query
+ ->orderBy(['n.name' => SORT_ASC])
+ ->offset($pagination->offset)
+ ->limit($pagination->limit)
+ ->all();
+
+ $mappingsByGuid = $this->loadMappingsForProducts(array_map(static fn($p) => $p->id, $products));
+
+ // Дополнительный фильтр по marking_id — уже применён в buildFilteredQuery через EXISTS,
+ // но для отображения маппингов показываем ВСЕ маппинги товара, не только matching.
+
+ return [
+ 'products' => $products,
+ 'mappingsByGuid' => $mappingsByGuid,
+ 'pagination' => $pagination,
+ ];
+ }
+
+ /**
+ * Аналитика для шапки: один агрегирующий SQL.
+ *
+ * @return array{
+ * total_actual: int,
+ * with_supplier: int,
+ * with_supplier_pct: float,
+ * without_producer: int,
+ * without_producer_pct: float,
+ * single_supplier: int,
+ * single_supplier_pct: float
+ * }
+ */
+ public function getAnalytics(ProductMappingFilterForm $filters): array
+ {
+ // Базовый запрос с теми же фильтрами что и getProductsWithMappings,
+ // но select — агрегирующий.
+ $query = $this->buildFilteredQuery($filters);
+
+ // Подзапрос со счётчиками маппингов по каждому товару.
+ // LEFT JOIN уже был добавлен в buildFilteredQuery — повторно не нужен.
+ // Но нам нужно переопределить select.
+
+ $query->select([
+ 'total' => new Expression('COUNT(*)'),
+ 'with_supplier' => new Expression('COUNT(*) FILTER (WHERE pm_agg.supplier_count > 0)'),
+ 'single_supplier' => new Expression('COUNT(*) FILTER (WHERE pm_agg.supplier_count = 1)'),
+ 'without_producer' => new Expression(
+ 'COUNT(*) FILTER (WHERE pm_agg.supplier_count > 0 AND COALESCE(pm_agg.has_producer, FALSE) = FALSE)'
+ ),
+ ]);
+
+ $row = $query->createCommand()->queryOne();
+
+ $total = (int)($row['total'] ?? 0);
+ $withSupplier = (int)($row['with_supplier'] ?? 0);
+ $single = (int)($row['single_supplier'] ?? 0);
+ $withoutProducer = (int)($row['without_producer'] ?? 0);
+
+ $pct = static fn(int $part, int $whole): float =>
+ $whole > 0 ? round(($part / $whole) * 100, 1) : 0.0;
+
+ return [
+ 'total_actual' => $total,
+ 'with_supplier' => $withSupplier,
+ 'with_supplier_pct' => $pct($withSupplier, $total),
+ 'without_producer' => $withoutProducer,
+ 'without_producer_pct' => $pct($withoutProducer, $withSupplier),
+ 'single_supplier' => $single,
+ 'single_supplier_pct' => $pct($single, $withSupplier),
+ ];
+ }
+
+ /**
+ * Справочники фильтров (кэшируются на 5 минут).
+ *
+ * @return array{
+ * categories: string[],
+ * subcategories: string[],
+ * species: string[]
+ * }
+ */
+ public function getCascadeFilters(?string $category = null, ?string $subcategory = null): array
+ {
+ $cacheKey = 'pm_cascade_filters_' . md5((string)$category . '|' . (string)$subcategory);
+
+ return Yii::$app->cache->getOrSet($cacheKey, function () use ($category, $subcategory) {
+ $categories = (new Query())
+ ->select('category')
+ ->from('products_1c_nomenclature')
+ ->where(['IS NOT', 'category', null])
+ ->andWhere(['!=', 'category', ''])
+ ->distinct()
+ ->orderBy('category')
+ ->column();
+
+ $subcategories = [];
+ if ($category !== null && $category !== '') {
+ $subcategories = (new Query())
+ ->select('subcategory')
+ ->from('products_1c_nomenclature')
+ ->where(['category' => $category])
+ ->andWhere(['IS NOT', 'subcategory', null])
+ ->andWhere(['!=', 'subcategory', ''])
+ ->distinct()
+ ->orderBy('subcategory')
+ ->column();
+ }
+
+ $species = [];
+ if ($category !== null && $category !== '' && $subcategory !== null && $subcategory !== '') {
+ $species = (new Query())
+ ->select('species')
+ ->from('products_1c_nomenclature')
+ ->where(['category' => $category, 'subcategory' => $subcategory])
+ ->andWhere(['IS NOT', 'species', null])
+ ->andWhere(['!=', 'species', ''])
+ ->distinct()
+ ->orderBy('species')
+ ->column();
+ }
+
+ return compact('categories', 'subcategories', 'species');
+ }, self::CACHE_DURATION);
+ }
+
+ /**
+ * Плоский список папок 1С.
+ *
+ * @return array<string,string> id => name
+ */
+ public function getFolders1c(): array
+ {
+ return Yii::$app->cache->getOrSet('pm_folders_1c', function () {
+ return (new Query())
+ ->select(['name', 'id'])
+ ->from('products_1c')
+ ->where(['tip' => 'products_group'])
+ ->orderBy('name')
+ ->indexBy('id')
+ ->column();
+ }, self::CACHE_DURATION);
+ }
+
+ /**
+ * Экспорт маппинга в .xlsx с учётом фильтров.
+ *
+ * Формат: одна строка на пару (товар, маппинг).
+ * Товары без маппингов — отдельной строкой с пустыми полями маппинга.
+ *
+ * @return string Абсолютный путь к временному файлу (вызывающий обязан удалить)
+ */
+ public function exportToXlsx(ProductMappingFilterForm $filters): string
+ {
+ // Все товары по фильтрам (без пагинации)
+ $query = $this->buildFilteredQuery($filters)->orderBy(['n.name' => SORT_ASC]);
+
+ /** @var Products1cNomenclature[] $products */
+ $products = $query->all();
+ $mappingsByGuid = $this->loadMappingsForProducts(
+ array_map(static fn($p) => $p->id, $products)
+ );
+
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('Маппинг товаров');
+
+ $headers = [
+ 'GUID товара',
+ 'Название товара 1С',
+ 'Категория',
+ 'Подкатегория',
+ 'Вид',
+ 'Поставщик',
+ 'Название у поставщика',
+ 'Плантация',
+ 'Артикул',
+ 'Штрихкод',
+ 'Квант',
+ 'Маркировки (коды)',
+ ];
+ $sheet->fromArray($headers, null, 'A1');
+
+ // Стиль заголовков: bold + серый фон
+ $lastCol = $sheet->getHighestColumn();
+ $sheet->getStyle("A1:{$lastCol}1")->getFont()->setBold(true);
+ $sheet->getStyle("A1:{$lastCol}1")->getFill()
+ ->setFillType(Fill::FILL_SOLID)
+ ->getStartColor()->setRGB('E9ECEF');
+ $sheet->getStyle("A1:{$lastCol}1")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+ $sheet->freezePane('A2');
+
+ $row = 2;
+ foreach ($products as $product) {
+ $mappings = $mappingsByGuid[$product->id] ?? [];
+
+ if (empty($mappings)) {
+ $sheet->fromArray([
+ $product->id,
+ $product->name,
+ $product->category ?? '',
+ $product->subcategory ?? '',
+ $product->species ?? '',
+ '', '', '', '', '', '', '',
+ ], null, "A{$row}");
+ $row++;
+ continue;
+ }
+
+ foreach ($mappings as $mapping) {
+ $markingCodes = [];
+ foreach ($mapping->markings as $m) {
+ $markingCodes[] = $m->code;
+ }
+ $sheet->fromArray([
+ $product->id,
+ $product->name,
+ $product->category ?? '',
+ $product->subcategory ?? '',
+ $product->species ?? '',
+ $mapping->supplier->name ?? '',
+ $mapping->supplier_product_name,
+ $mapping->plantation->name ?? '',
+ $mapping->article ?? '',
+ $mapping->barcode ?? '',
+ (int)$mapping->quant,
+ implode(', ', $markingCodes),
+ ], null, "A{$row}");
+ $row++;
+ }
+ }
+
+ // Авто-ширина колонок
+ foreach (range('A', $lastCol) as $col) {
+ $sheet->getColumnDimension($col)->setAutoSize(true);
+ }
+
+ $runtimeDir = Yii::getAlias('@runtime');
+ if (!is_dir($runtimeDir)) {
+ mkdir($runtimeDir, 0775, true);
+ }
+ $path = $runtimeDir . '/product-mapping-export-' . date('YmdHis') . '-' . uniqid() . '.xlsx';
+
+ (new Xlsx($spreadsheet))->save($path);
+ $spreadsheet->disconnectWorksheets();
+ unset($spreadsheet);
+
+ return $path;
+ }
+
+ /**
+ * Найти маппинги-orphans (product_guid не существует в products_1c).
+ *
+ * @return array<int,array{id:int,product_guid:string,supplier_id:int,supplier_product_name:string}>
+ */
+ public function findOrphanMappings(): array
+ {
+ $rows = (new Query())
+ ->select([
+ 'id',
+ 'product_guid',
+ 'supplier_id',
+ 'supplier_product_name',
+ ])
+ ->from('{{%erp24.product_mappings}} pm')
+ ->where([
+ 'NOT EXISTS',
+ (new Query())
+ ->select(new Expression('1'))
+ ->from('products_1c p1c')
+ ->where('p1c.id = pm.product_guid'),
+ ])
+ ->orderBy(['id' => SORT_ASC])
+ ->all();
+
+ return $rows;
+ }
+
+ /**
+ * Удалить orphan-маппинги.
+ * junction-записи mapping_markings удалятся каскадно (ON DELETE CASCADE).
+ *
+ * @return int Количество удалённых маппингов
+ */
+ public function deleteOrphanMappings(): int
+ {
+ $transaction = Yii::$app->db->beginTransaction();
+ try {
+ $affected = (int)Yii::$app->db->createCommand(
+ 'DELETE FROM {{%erp24.product_mappings}} pm '
+ . 'WHERE NOT EXISTS (SELECT 1 FROM products_1c p1c WHERE p1c.id = pm.product_guid)'
+ )->execute();
+
+ $transaction->commit();
+ Yii::info(
+ sprintf('cleanup-orphans: удалено маппингов без товара 1С: %d', $affected),
+ 'product-mapping'
+ );
+ return $affected;
+ } catch (\Throwable $e) {
+ $transaction->rollBack();
+ throw $e;
+ }
+ }
+
+ /* =====================================================
+ * INTERNAL
+ * ===================================================== */
+
+ /**
+ * Построение базового запроса с фильтрами.
+ * Всегда делает LEFT JOIN на агрегированный подзапрос маппингов (для аналитики и only_without_supplier).
+ */
+ private function buildFilteredQuery(ProductMappingFilterForm $filters): \yii\db\ActiveQuery
+ {
+ $mappingsSub = (new Query())
+ ->select([
+ 'product_guid',
+ 'supplier_count' => new Expression('COUNT(DISTINCT supplier_id)'),
+ 'has_producer' => new Expression('BOOL_OR(plantation_id IS NOT NULL)'),
+ ])
+ ->from('{{%erp24.product_mappings}}')
+ ->groupBy('product_guid');
+
+ $query = Products1cNomenclature::find()
+ ->alias('n')
+ ->leftJoin(['pm_agg' => $mappingsSub], 'pm_agg.product_guid = n.id');
+
+ // Whitelist: явные привязки, никаких пользовательских имён колонок
+ if ($filters->category !== null) {
+ $query->andWhere(['n.category' => $filters->category]);
+ }
+ if ($filters->subcategory !== null) {
+ $query->andWhere(['n.subcategory' => $filters->subcategory]);
+ }
+ if ($filters->species !== null) {
+ $query->andWhere(['n.species' => $filters->species]);
+ }
+ if ($filters->search !== null) {
+ $query->andWhere(['ilike', 'n.name', $filters->search]);
+ }
+
+ // Папка 1С — фильтр через products_1c.parent_id
+ if ($filters->folder_1c !== null) {
+ $query->andWhere([
+ 'EXISTS',
+ (new Query())
+ ->select(new Expression('1'))
+ ->from('products_1c p1c')
+ ->where('p1c.id = n.id')
+ ->andWhere(['p1c.parent_id' => $filters->folder_1c]),
+ ]);
+ }
+
+ // Поставщик — через EXISTS в product_mappings
+ if ($filters->supplier_id !== null) {
+ $query->andWhere([
+ 'EXISTS',
+ (new Query())
+ ->select(new Expression('1'))
+ ->from('{{%erp24.product_mappings}} pm_f')
+ ->where('pm_f.product_guid = n.id')
+ ->andWhere(['pm_f.supplier_id' => $filters->supplier_id]),
+ ]);
+ }
+
+ // Маркировка — через EXISTS по junction mapping_markings
+ if ($filters->marking_id !== null) {
+ $query->andWhere([
+ 'EXISTS',
+ (new Query())
+ ->select(new Expression('1'))
+ ->from('{{%erp24.product_mappings}} pm_mk')
+ ->innerJoin(
+ '{{%erp24.mapping_markings}} mm_f',
+ 'mm_f.mapping_id = pm_mk.id'
+ )
+ ->where('pm_mk.product_guid = n.id')
+ ->andWhere(['mm_f.marking_id' => $filters->marking_id]),
+ ]);
+ }
+
+ // Только товары без маппинга
+ if ($filters->only_without_supplier) {
+ $query->andWhere(['IS', 'pm_agg.product_guid', null]);
+ }
+
+ // Актуальность
+ if ($filters->is_actual === true) {
+ $query->andWhere([
+ 'EXISTS',
+ (new Query())
+ ->select(new Expression('1'))
+ ->from('products_1c_nomenclature_actuality a')
+ ->where('a.guid = n.id')
+ ->andWhere(['<=', 'a.date_from', new Expression('NOW()')])
+ ->andWhere([
+ 'or',
+ ['a.date_to' => null],
+ ['>=', 'a.date_to', new Expression('NOW()')],
+ ]),
+ ]);
+ } elseif ($filters->is_actual === false) {
+ $query->andWhere([
+ 'NOT EXISTS',
+ (new Query())
+ ->select(new Expression('1'))
+ ->from('products_1c_nomenclature_actuality a')
+ ->where('a.guid = n.id')
+ ->andWhere(['<=', 'a.date_from', new Expression('NOW()')])
+ ->andWhere([
+ 'or',
+ ['a.date_to' => null],
+ ['>=', 'a.date_to', new Expression('NOW()')],
+ ]),
+ ]);
+ }
+
+ return $query;
+ }
+
+ /**
+ * Загрузить маппинги пачкой для указанных product_guid.
+ *
+ * @param string[] $productIds
+ * @return array<string,ProductMapping[]>
+ */
+ private function loadMappingsForProducts(array $productIds): array
+ {
+ if (empty($productIds)) {
+ return [];
+ }
+
+ /** @var ProductMapping[] $allMappings */
+ $allMappings = ProductMapping::find()
+ ->where(['product_guid' => $productIds])
+ ->with(['supplier', 'plantation', 'markings'])
+ ->orderBy(['supplier_id' => SORT_ASC])
+ ->all();
+
+ $grouped = [];
+ foreach ($allMappings as $m) {
+ $grouped[$m->product_guid][] = $m;
+ }
+ return $grouped;
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\commands;
+
+use Codeception\Test\Unit;
+use ReflectionClass;
+use yii_app\commands\ProductMappingController;
+
+/**
+ * Unit-тесты console-команды product-mapping/cleanup-orphans (ERP-323).
+ *
+ * @group erp300
+ * @group product-mapping
+ * @group console
+ * @covers \yii_app\commands\ProductMappingController
+ */
+class ProductMappingCommandTest extends Unit
+{
+ public function testCleanupOrphansActionExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingController::class, 'actionCleanupOrphans'));
+ }
+
+ public function testDeleteOptionIsBool(): void
+ {
+ $reflection = new ReflectionClass(ProductMappingController::class);
+ $this->assertTrue($reflection->hasProperty('delete'));
+
+ $prop = $reflection->getProperty('delete');
+ $type = $prop->getType();
+ $this->assertNotNull($type);
+ $this->assertSame('bool', $type->getName());
+ }
+
+ public function testDeleteOptionDefaultIsFalse(): void
+ {
+ $controller = new ProductMappingController('product-mapping', null);
+ $this->assertFalse($controller->delete, 'Dry-run по умолчанию — delete=false');
+ }
+
+ public function testDeleteIsRegisteredInOptions(): void
+ {
+ $controller = new ProductMappingController('product-mapping', null);
+ $options = $controller->options('cleanup-orphans');
+ $this->assertContains('delete', $options);
+ }
+
+ public function testDeleteHasShortAlias(): void
+ {
+ $controller = new ProductMappingController('product-mapping', null);
+ $aliases = $controller->optionAliases();
+ $this->assertArrayHasKey('d', $aliases);
+ $this->assertSame('delete', $aliases['d']);
+ }
+
+ public function testExtendsConsoleController(): void
+ {
+ $reflection = new ReflectionClass(ProductMappingController::class);
+ $this->assertTrue($reflection->isSubclassOf(\yii\console\Controller::class));
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\controllers;
+
+use Codeception\Test\Unit;
+use ReflectionClass;
+
+/**
+ * Regression-тесты контроллеров эпика ERP-300.
+ *
+ * Проверяет наличие expected actions в каждом контроллере и наследование
+ * от BaseController (который применяет RBAC через menu/{controller}/{action}).
+ *
+ * Если action переименован/удалён — тест упадёт и напомнит обновить:
+ * 1) RBAC миграцию m260409_120000_*
+ * 2) JS в соответствующей view
+ *
+ * @group erp300
+ * @group controllers
+ */
+class Erp300ControllersTest extends Unit
+{
+ /**
+ * @dataProvider controllersProvider
+ */
+ public function testControllerHasExpectedActions(string $controllerClass, array $expectedActions): void
+ {
+ $this->assertTrue(
+ class_exists($controllerClass),
+ "Контроллер {$controllerClass} должен существовать"
+ );
+
+ foreach ($expectedActions as $action) {
+ $method = 'action' . $action;
+ $this->assertTrue(
+ method_exists($controllerClass, $method),
+ "Контроллер {$controllerClass} должен иметь {$method}()"
+ );
+ }
+ }
+
+ /**
+ * @dataProvider controllersProvider
+ */
+ public function testControllerExtendsBaseController(string $controllerClass): void
+ {
+ $reflection = new ReflectionClass($controllerClass);
+ $parent = $reflection->getParentClass();
+
+ $this->assertNotFalse($parent);
+ $this->assertSame(
+ 'app\controllers\BaseController',
+ $parent->getName(),
+ "Контроллер {$controllerClass} должен наследовать BaseController"
+ );
+ }
+
+ /**
+ * @return array<string,array{string,string[]}>
+ */
+ public static function controllersProvider(): array
+ {
+ return [
+ 'BuyerReferenceController' => [
+ 'app\controllers\BuyerReferenceController',
+ ['Index'],
+ ],
+ 'SupplierController' => [
+ 'app\controllers\SupplierController',
+ ['Index', 'CreateForm', 'Create', 'UpdateForm', 'Update', 'Delete'],
+ ],
+ 'ProducerController' => [
+ 'app\controllers\ProducerController',
+ [
+ 'Index',
+ 'CreateProducerForm', 'CreateProducer', 'UpdateProducerForm',
+ 'UpdateProducer', 'DeleteProducer',
+ 'CreatePlantationForm', 'CreatePlantation', 'UpdatePlantationForm',
+ 'UpdatePlantation', 'DeletePlantation',
+ ],
+ ],
+ 'MarkingController' => [
+ 'app\controllers\MarkingController',
+ ['Index', 'CreateForm', 'Create', 'UpdateForm', 'Update', 'Delete', 'PlantationsByProducer'],
+ ],
+ 'ProductMappingController' => [
+ 'app\controllers\ProductMappingController',
+ [
+ 'Index',
+ 'CreateForm', 'Create', 'UpdateForm', 'Update', 'Delete',
+ 'Analytics', 'CascadeFilters', 'Export',
+ ],
+ ],
+ ];
+ }
+
+ public function testBaseControllerAppliesRbacViaMatchCallback(): void
+ {
+ $baseControllerPath = dirname(__DIR__, 3) . '/controllers/BaseController.php';
+ $this->assertFileExists($baseControllerPath);
+
+ $content = file_get_contents($baseControllerPath);
+ $this->assertStringContainsString(
+ 'menu/',
+ $content,
+ 'BaseController должен применять permission menu/{controller}/{action}'
+ );
+ $this->assertStringContainsString(
+ 'Yii::$app->user->can',
+ $content,
+ 'BaseController должен использовать can() для проверки permission'
+ );
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\forms;
+
+use Codeception\Test\Unit;
+use yii_app\forms\ProductMappingFilterForm;
+
+/**
+ * Unit-тесты для ProductMappingFilterForm (ERP-322).
+ *
+ * Главное — whitelist: неизвестные поля игнорируются,
+ * пустые строки → null, boolean handling.
+ *
+ * @group erp300
+ * @group product-mapping
+ * @group filter
+ * @covers \yii_app\forms\ProductMappingFilterForm
+ */
+class ProductMappingFilterFormTest extends Unit
+{
+ public function testEmptyFormIsEmpty(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $this->assertTrue($form->isEmpty());
+ $this->assertSame([], $form->getActiveFilters());
+ }
+
+ public function testLoadFiltersIgnoresUnknownKeys(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters([
+ 'category' => 'Цветы',
+ 'malicious_field' => "'; DROP TABLE users; --",
+ 'another_unknown' => 'ignored',
+ ]);
+
+ $this->assertSame('Цветы', $form->category);
+ // Неизвестное поле не должно быть создано или установлено
+ $this->assertFalse(property_exists($form, 'malicious_field'));
+ }
+
+ public function testLoadFiltersConvertsEmptyStringsToNull(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters([
+ 'category' => '',
+ 'subcategory' => '',
+ 'species' => '',
+ ]);
+
+ $this->assertNull($form->category);
+ $this->assertNull($form->subcategory);
+ $this->assertNull($form->species);
+ }
+
+ public function testLoadFiltersHandlesBooleanStrings(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters([
+ 'only_without_supplier' => '1',
+ ]);
+
+ $this->assertTrue($form->only_without_supplier);
+ }
+
+ public function testIsActualCanBeTrueFalseOrNull(): void
+ {
+ $form1 = new ProductMappingFilterForm();
+ $form1->loadFilters(['is_actual' => '1']);
+ $this->assertTrue($form1->is_actual);
+
+ $form2 = new ProductMappingFilterForm();
+ $form2->loadFilters(['is_actual' => '0']);
+ $this->assertFalse($form2->is_actual);
+
+ $form3 = new ProductMappingFilterForm();
+ $form3->loadFilters([]);
+ $this->assertNull($form3->is_actual);
+ }
+
+ public function testGetActiveFiltersContainsOnlyActiveFilters(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters([
+ 'category' => 'Цветы',
+ 'subcategory' => '',
+ 'search' => 'розы',
+ ]);
+
+ $data = $form->getActiveFilters();
+ $this->assertArrayHasKey('category', $data);
+ $this->assertArrayHasKey('search', $data);
+ $this->assertArrayNotHasKey('subcategory', $data);
+ $this->assertArrayNotHasKey('supplier_id', $data);
+ }
+
+ public function testGetActiveFiltersOmitsOnlyWithoutSupplierWhenFalse(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters([]);
+ $this->assertArrayNotHasKey('only_without_supplier', $form->getActiveFilters());
+ }
+
+ public function testGetActiveFiltersIncludesOnlyWithoutSupplierWhenTrue(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters(['only_without_supplier' => '1']);
+ $data = $form->getActiveFilters();
+ $this->assertArrayHasKey('only_without_supplier', $data);
+ $this->assertTrue($data['only_without_supplier']);
+ }
+
+ public function testFormNameIsEmpty(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $this->assertSame('', $form->formName());
+ }
+
+ public function testIsEmptyReturnsFalseWhenAnyFilterSet(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $form->loadFilters(['search' => 'роза']);
+ $this->assertFalse($form->isEmpty());
+ }
+
+ public function testDefaultValuesAfterConstruction(): void
+ {
+ $form = new ProductMappingFilterForm();
+ $this->assertNull($form->category);
+ $this->assertNull($form->subcategory);
+ $this->assertNull($form->species);
+ $this->assertNull($form->folder_1c);
+ $this->assertNull($form->supplier_id);
+ $this->assertNull($form->marking_id);
+ $this->assertNull($form->is_actual);
+ $this->assertFalse($form->only_without_supplier);
+ $this->assertNull($form->search);
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\migrations;
+
+use Codeception\Test\Unit;
+
+/**
+ * Regression-тест RBAC миграции ERP-300.
+ *
+ * Гарантирует, что миграция m260409_120000_add_erp300_rbac_permissions
+ * содержит permissions для всех контроллеров эпика.
+ *
+ * Если добавили новый action в контроллер — добавьте сюда permission и в миграцию.
+ *
+ * @group erp300
+ * @group rbac
+ * @group migrations
+ */
+class Erp300RbacMigrationTest extends Unit
+{
+ private string $migrationPath;
+ private string $migrationContent;
+
+ protected function _before(): void
+ {
+ $this->migrationPath = dirname(__DIR__, 3)
+ . '/migrations/m260409_120000_add_erp300_rbac_permissions.php';
+
+ $this->assertFileExists($this->migrationPath, 'Миграция должна существовать');
+ $this->migrationContent = file_get_contents($this->migrationPath);
+ }
+
+ public function testMigrationTargetsItAndDirectorGroups(): void
+ {
+ // Группа IT = 81, Director = 1
+ $this->assertMatchesRegularExpression(
+ '/TARGET_GROUP_IDS\s*=\s*\[[^\]]*\b81\b/',
+ $this->migrationContent,
+ 'Должна быть группа IT (81)'
+ );
+ $this->assertMatchesRegularExpression(
+ '/TARGET_GROUP_IDS\s*=\s*\[[^\]]*\b1\b/',
+ $this->migrationContent,
+ 'Должна быть группа Director (1)'
+ );
+ }
+
+ /**
+ * @dataProvider requiredPermissionsProvider
+ */
+ public function testPermissionExistsInMigration(string $permission, string $story): void
+ {
+ $this->assertStringContainsString(
+ "'{$permission}'",
+ $this->migrationContent,
+ "Permission {$permission} ({$story}) должен быть в миграции"
+ );
+ }
+
+ /**
+ * @return array<string,array{string,string}>
+ */
+ public static function requiredPermissionsProvider(): array
+ {
+ return [
+ // ERP-317: главная страница
+ ['menu/buyer-reference/index', 'ERP-317'],
+
+ // ERP-318: Supplier
+ ['menu/supplier/index', 'ERP-318'],
+ ['menu/supplier/create', 'ERP-318'],
+ ['menu/supplier/create-form', 'ERP-318'],
+ ['menu/supplier/update', 'ERP-318'],
+ ['menu/supplier/update-form', 'ERP-318'],
+ ['menu/supplier/delete', 'ERP-318'],
+
+ // ERP-319: Producer/Plantation
+ ['menu/producer/index', 'ERP-319'],
+ ['menu/producer/create-producer', 'ERP-319'],
+ ['menu/producer/create-producer-form', 'ERP-319'],
+ ['menu/producer/update-producer', 'ERP-319'],
+ ['menu/producer/update-producer-form', 'ERP-319'],
+ ['menu/producer/delete-producer', 'ERP-319'],
+ ['menu/producer/create-plantation', 'ERP-319'],
+ ['menu/producer/create-plantation-form', 'ERP-319'],
+ ['menu/producer/update-plantation', 'ERP-319'],
+ ['menu/producer/update-plantation-form', 'ERP-319'],
+ ['menu/producer/delete-plantation', 'ERP-319'],
+
+ // ERP-320: Marking
+ ['menu/marking/index', 'ERP-320'],
+ ['menu/marking/create', 'ERP-320'],
+ ['menu/marking/create-form', 'ERP-320'],
+ ['menu/marking/update', 'ERP-320'],
+ ['menu/marking/update-form', 'ERP-320'],
+ ['menu/marking/delete', 'ERP-320'],
+ ['menu/marking/plantations-by-producer', 'ERP-320'],
+
+ // ERP-321: ProductMapping CRUD
+ ['menu/product-mapping/index', 'ERP-321'],
+ ['menu/product-mapping/create-form', 'ERP-321'],
+ ['menu/product-mapping/create', 'ERP-321'],
+ ['menu/product-mapping/update-form', 'ERP-321'],
+ ['menu/product-mapping/update', 'ERP-321'],
+ ['menu/product-mapping/delete', 'ERP-321'],
+
+ // ERP-322: Filters + Analytics
+ ['menu/product-mapping/analytics', 'ERP-322'],
+ ['menu/product-mapping/cascade-filters', 'ERP-322'],
+
+ // ERP-323: Export
+ ['menu/product-mapping/export', 'ERP-323'],
+ ];
+ }
+
+ public function testMigrationResetsCacheFlag(): void
+ {
+ $this->assertStringContainsString(
+ "dirtyAuthSettings",
+ $this->migrationContent,
+ 'Миграция должна сбрасывать кеш dirtyAuthSettings'
+ );
+ }
+
+ public function testMigrationHasSafeDown(): void
+ {
+ $this->assertStringContainsString(
+ 'public function safeDown()',
+ $this->migrationContent,
+ 'Должен быть rollback через safeDown'
+ );
+ }
+
+ public function testMigrationInstructsToRunAuthInit(): void
+ {
+ $this->assertStringContainsString(
+ 'auth/init',
+ $this->migrationContent,
+ 'Миграция должна напомнить запустить auth/init'
+ );
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\Marking;
+
+/**
+ * Unit-тесты для Marking (ERP-320).
+ *
+ * Главное — regex валидации кода маркировки.
+ * Regex: /^[A-Z0-9\-]{3,12}$/
+ *
+ * @group erp300
+ * @group marking
+ * @covers \yii_app\records\Marking
+ */
+class MarkingTest extends Unit
+{
+ public function testCodeRegexConstantExists(): void
+ {
+ $this->assertNotEmpty(Marking::CODE_REGEX);
+ $this->assertSame('/^[A-Z0-9\-]{3,12}$/D', Marking::CODE_REGEX);
+ }
+
+ /**
+ * @dataProvider validCodesProvider
+ */
+ public function testValidCodesPassRegex(string $code, string $description): void
+ {
+ $this->assertMatchesRegularExpression(
+ Marking::CODE_REGEX,
+ $code,
+ "Код '{$code}' ({$description}) должен быть валиден"
+ );
+ }
+
+ /**
+ * @return array<string,array{string,string}>
+ */
+ public static function validCodesProvider(): array
+ {
+ return [
+ 'минимум 3 символа' => ['ABC', 'минимально возможная длина'],
+ 'только цифры' => ['123', 'только цифры'],
+ 'максимум 12 символов' => ['ABCDEFGHIJKL', '12 символов'],
+ 'с одним дефисом' => ['SCH-RP', 'обычный код из ТЗ'],
+ 'с двумя дефисами' => ['SCH-RP-RN50', 'пример из документации'],
+ 'много дефисов' => ['A-B-C-D-1-2', 'максимум дефисов'],
+ 'смесь букв и цифр' => ['AB12CD34', 'буквы и цифры без дефисов'],
+ 'дефис в начале' => ['-ABC', 'дефис в начале (разрешено)'],
+ 'дефис в конце' => ['ABC-', 'дефис в конце (разрешено)'],
+ 'код с цифрами размера' => ['RN50-60', 'с указанием размера'],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidCodesProvider
+ */
+ public function testInvalidCodesFailRegex(string $code, string $description): void
+ {
+ $this->assertDoesNotMatchRegularExpression(
+ Marking::CODE_REGEX,
+ $code,
+ "Код '{$code}' ({$description}) НЕ должен проходить валидацию"
+ );
+ }
+
+ /**
+ * @return array<string,array{string,string}>
+ */
+ public static function invalidCodesProvider(): array
+ {
+ return [
+ 'пустая строка' => ['', 'пустая строка'],
+ 'короче 3 символов' => ['AB', '2 символа — меньше минимума'],
+ 'длиннее 12 символов' => ['ABCDEFGHIJKLM', '13 символов — больше максимума'],
+ 'строчные буквы' => ['sch-rp', 'lowercase недопустим'],
+ 'смешанный регистр' => ['Sch-Rp', 'mixed case недопустим'],
+ 'с пробелом' => ['AB CD', 'пробел недопустим'],
+ 'с подчёркиванием' => ['AB_CD', 'underscore недопустим'],
+ 'с точкой' => ['AB.CD', 'точка недопустима'],
+ 'с кириллицей' => ['АБВ', 'кириллица недопустима'],
+ 'с юникод-дефисом' => ['AB–CD', 'en-dash (U+2013) недопустим'],
+ 'со слешем' => ['AB/CD', 'слеш недопустим'],
+ 'со спец. символом' => ['AB@CD', '@ недопустим'],
+ 'с переводом строки' => ["ABC\n", 'перевод строки недопустим'],
+ ];
+ }
+
+ public function testTableNameUsesSchemaErp24(): void
+ {
+ $this->assertStringContainsString('erp24.markings', Marking::tableName());
+ }
+
+ public function testRulesRequireAllMainFields(): void
+ {
+ $rules = (new Marking())->rules();
+ $requiredFields = [];
+ foreach ($rules as $rule) {
+ if (isset($rule[1]) && $rule[1] === 'required') {
+ $requiredFields = array_merge($requiredFields, (array)$rule[0]);
+ }
+ }
+
+ foreach (['code', 'producer_id', 'plantation_id', 'product_name', 'supplier_id'] as $field) {
+ $this->assertContains($field, $requiredFields, "Поле {$field} должно быть required");
+ }
+ }
+
+ public function testRulesContainCodeMatchValidator(): void
+ {
+ $rules = (new Marking())->rules();
+ $hasMatch = false;
+ foreach ($rules as $rule) {
+ if (
+ isset($rule[1], $rule[0])
+ && $rule[1] === 'match'
+ && in_array('code', (array)$rule[0], true)
+ ) {
+ $hasMatch = true;
+ $this->assertSame(Marking::CODE_REGEX, $rule['pattern'] ?? null);
+ break;
+ }
+ }
+ $this->assertTrue($hasMatch, 'Должно быть правило "match" для code с CODE_REGEX');
+ }
+
+ public function testRulesContainCodeUppercaseFilter(): void
+ {
+ $rules = (new Marking())->rules();
+ $hasFilter = false;
+ foreach ($rules as $rule) {
+ if (
+ isset($rule[1], $rule[0])
+ && $rule[1] === 'filter'
+ && in_array('code', (array)$rule[0], true)
+ && ($rule['filter'] ?? null) === 'strtoupper'
+ ) {
+ $hasFilter = true;
+ break;
+ }
+ }
+ $this->assertTrue($hasFilter, 'Должен быть фильтр strtoupper для code');
+ }
+
+ public function testStatusBadgeMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Marking::class, 'getStatusBadge'));
+ }
+
+ public function testRelationsMethodsExist(): void
+ {
+ $this->assertTrue(method_exists(Marking::class, 'getProducer'));
+ $this->assertTrue(method_exists(Marking::class, 'getPlantation'));
+ $this->assertTrue(method_exists(Marking::class, 'getSupplier'));
+ }
+
+ public function testGetMappingsCountMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Marking::class, 'getMappingsCount'));
+ }
+
+ public function testDeactivateMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Marking::class, 'deactivate'));
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\Plantation;
+
+/**
+ * Unit-тесты для Plantation (ERP-319).
+ *
+ * @group erp300
+ * @group plantation
+ * @covers \yii_app\records\Plantation
+ */
+class PlantationTest extends Unit
+{
+ public function testTableNameUsesSchemaErp24(): void
+ {
+ $this->assertStringContainsString('erp24.plantations', Plantation::tableName());
+ }
+
+ public function testCountryOptionsContainsKeyCountries(): void
+ {
+ $countries = Plantation::getCountryOptions();
+
+ foreach (['Эквадор', 'Колумбия', 'Кения', 'Эфиопия', 'Нидерланды'] as $country) {
+ $this->assertArrayHasKey($country, $countries, "Должна быть страна: {$country}");
+ }
+ }
+
+ public function testCountryOptionsAtLeast9Items(): void
+ {
+ $this->assertGreaterThanOrEqual(9, count(Plantation::getCountryOptions()));
+ }
+
+ public function testCountryOptionsKeysEqualValues(): void
+ {
+ $countries = Plantation::getCountryOptions();
+ foreach ($countries as $key => $value) {
+ $this->assertSame($key, $value, 'Ключ и значение страны должны совпадать');
+ }
+ }
+
+ public function testRulesRequireNameProducerCountry(): void
+ {
+ $rules = (new Plantation())->rules();
+ $requiredFields = [];
+ foreach ($rules as $rule) {
+ if (isset($rule[1]) && $rule[1] === 'required') {
+ $requiredFields = array_merge($requiredFields, (array)$rule[0]);
+ }
+ }
+
+ $this->assertContains('name', $requiredFields);
+ $this->assertContains('producer_id', $requiredFields);
+ $this->assertContains('country', $requiredFields);
+ }
+
+ public function testRulesValidateCountryAgainstWhitelist(): void
+ {
+ $rules = (new Plantation())->rules();
+ $hasInRule = false;
+ foreach ($rules as $rule) {
+ if (
+ isset($rule[1], $rule[0])
+ && $rule[1] === 'in'
+ && in_array('country', (array)$rule[0], true)
+ ) {
+ $hasInRule = true;
+ break;
+ }
+ }
+ $this->assertTrue($hasInRule, 'Должно быть правило "in" для country (whitelist)');
+ }
+
+ public function testStatusBadgeMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Plantation::class, 'getStatusBadge'));
+ }
+
+ public function testDeactivateMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Plantation::class, 'deactivate'));
+ }
+
+ public function testHasActiveMarkingsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Plantation::class, 'hasActiveMarkings'));
+ }
+
+ public function testProducerRelationExists(): void
+ {
+ $this->assertTrue(method_exists(Plantation::class, 'getProducer'));
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\Producer;
+
+/**
+ * Unit-тесты для Producer (ERP-319).
+ *
+ * @group erp300
+ * @group producer
+ * @covers \yii_app\records\Producer
+ */
+class ProducerTest extends Unit
+{
+ public function testTableNameUsesSchemaErp24(): void
+ {
+ $this->assertStringContainsString('erp24.producers', Producer::tableName());
+ }
+
+ public function testStatusBadgeMethodExists(): void
+ {
+ // Рендер badge требует установки атрибута AR → DB schema.
+ // Проверяем только наличие метода.
+ $this->assertTrue(method_exists(Producer::class, 'getStatusBadge'));
+ }
+
+ public function testRulesRequireName(): void
+ {
+ $rules = (new Producer())->rules();
+ $found = false;
+ foreach ($rules as $rule) {
+ if (isset($rule[1]) && $rule[1] === 'required' && in_array('name', (array)$rule[0], true)) {
+ $found = true;
+ break;
+ }
+ }
+ $this->assertTrue($found, 'Поле name должно быть required');
+ }
+
+ public function testRelationsMethodsExist(): void
+ {
+ $this->assertTrue(method_exists(Producer::class, 'getPlantations'));
+ $this->assertTrue(method_exists(Producer::class, 'getActivePlantations'));
+ }
+
+ public function testDeactivateMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Producer::class, 'deactivate'));
+ }
+
+ public function testHasActiveMarkingsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Producer::class, 'hasActiveMarkings'));
+ }
+
+ public function testAttributeLabelsContainName(): void
+ {
+ $labels = (new Producer())->attributeLabels();
+ $this->assertArrayHasKey('name', $labels);
+ $this->assertSame('Название', $labels['name']);
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\ProductMapping;
+
+/**
+ * Unit-тесты для ProductMapping (ERP-321).
+ *
+ * @group erp300
+ * @group product-mapping
+ * @covers \yii_app\records\ProductMapping
+ */
+class ProductMappingTest extends Unit
+{
+ public function testTableNameUsesSchemaErp24(): void
+ {
+ $this->assertStringContainsString('erp24.product_mappings', ProductMapping::tableName());
+ }
+
+ public function testMarkingIdsDefaultIsEmptyArray(): void
+ {
+ $model = new ProductMapping();
+ $this->assertIsArray($model->marking_ids);
+ $this->assertSame([], $model->marking_ids);
+ }
+
+ public function testMarkingIdsIsVirtualField(): void
+ {
+ $model = new ProductMapping();
+ $model->marking_ids = [1, 2, 3];
+ $this->assertSame([1, 2, 3], $model->marking_ids);
+ }
+
+ public function testQuantDefaultIsOne(): void
+ {
+ $model = new ProductMapping();
+ $rules = $model->rules();
+
+ $hasDefault = false;
+ foreach ($rules as $rule) {
+ if (
+ isset($rule[1], $rule[0])
+ && $rule[1] === 'default'
+ && in_array('quant', (array)$rule[0], true)
+ && ($rule['value'] ?? null) === 1
+ ) {
+ $hasDefault = true;
+ break;
+ }
+ }
+ $this->assertTrue($hasDefault, 'Квант должен иметь default=1');
+ }
+
+ public function testRulesRequireMainFields(): void
+ {
+ $rules = (new ProductMapping())->rules();
+ $requiredFields = [];
+ foreach ($rules as $rule) {
+ if (isset($rule[1]) && $rule[1] === 'required') {
+ $requiredFields = array_merge($requiredFields, (array)$rule[0]);
+ }
+ }
+
+ foreach (['product_guid', 'supplier_id', 'supplier_product_name', 'quant'] as $field) {
+ $this->assertContains($field, $requiredFields);
+ }
+ }
+
+ public function testPlantationIdIsNotRequired(): void
+ {
+ $rules = (new ProductMapping())->rules();
+ $requiredFields = [];
+ foreach ($rules as $rule) {
+ if (isset($rule[1]) && $rule[1] === 'required') {
+ $requiredFields = array_merge($requiredFields, (array)$rule[0]);
+ }
+ }
+ $this->assertNotContains('plantation_id', $requiredFields, 'plantation_id nullable');
+ }
+
+ public function testCustomValidatorsExist(): void
+ {
+ $this->assertTrue(method_exists(ProductMapping::class, 'validateProductGuidExists'));
+ $this->assertTrue(method_exists(ProductMapping::class, 'validatePartialUnique'));
+ }
+
+ public function testSyncMarkingsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMapping::class, 'syncMarkings'));
+ }
+
+ public function testGetCurrentMarkingIdsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMapping::class, 'getCurrentMarkingIds'));
+ }
+
+ public function testRelationsMethodsExist(): void
+ {
+ $this->assertTrue(method_exists(ProductMapping::class, 'getSupplier'));
+ $this->assertTrue(method_exists(ProductMapping::class, 'getPlantation'));
+ $this->assertTrue(method_exists(ProductMapping::class, 'getMarkings'));
+ $this->assertTrue(method_exists(ProductMapping::class, 'getProduct'));
+ $this->assertTrue(method_exists(ProductMapping::class, 'getProductNomenclature'));
+ }
+
+ public function testMarkingIdsRuleHasDefault(): void
+ {
+ $rules = (new ProductMapping())->rules();
+ $hasDefault = false;
+ foreach ($rules as $rule) {
+ if (
+ isset($rule[1], $rule[0])
+ && $rule[1] === 'default'
+ && in_array('marking_ids', (array)$rule[0], true)
+ && ($rule['value'] ?? null) === []
+ ) {
+ $hasDefault = true;
+ break;
+ }
+ }
+ $this->assertTrue($hasDefault, 'marking_ids должен иметь default=[]');
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\Supplier;
+
+/**
+ * Unit-тесты для Supplier (ERP-318).
+ *
+ * Покрывает:
+ * - Константы типов и валют
+ * - getTypeOptions / getCurrencyOptions
+ * - getTypeBadge / getStatusBadge (HTML)
+ * - attributeLabels
+ * - Структура rules() без DB-валидаторов
+ *
+ * @group erp300
+ * @group supplier
+ * @covers \yii_app\records\Supplier
+ */
+class SupplierTest extends Unit
+{
+ public function testTypeConstantsExist(): void
+ {
+ $this->assertSame('local', Supplier::TYPE_LOCAL);
+ $this->assertSame('international', Supplier::TYPE_INTERNATIONAL);
+ }
+
+ public function testCurrencyConstantsExist(): void
+ {
+ $this->assertSame('RUB', Supplier::CURRENCY_RUB);
+ $this->assertSame('EUR', Supplier::CURRENCY_EUR);
+ $this->assertSame('USD', Supplier::CURRENCY_USD);
+ }
+
+ public function testGetTypeOptionsReturnsBothTypes(): void
+ {
+ $options = Supplier::getTypeOptions();
+ $this->assertArrayHasKey(Supplier::TYPE_LOCAL, $options);
+ $this->assertArrayHasKey(Supplier::TYPE_INTERNATIONAL, $options);
+ $this->assertSame('Локальный', $options[Supplier::TYPE_LOCAL]);
+ $this->assertSame('Международный', $options[Supplier::TYPE_INTERNATIONAL]);
+ }
+
+ public function testGetCurrencyOptionsReturnsAllThree(): void
+ {
+ $options = Supplier::getCurrencyOptions();
+ $this->assertCount(3, $options);
+ $this->assertArrayHasKey('RUB', $options);
+ $this->assertArrayHasKey('EUR', $options);
+ $this->assertArrayHasKey('USD', $options);
+ }
+
+ public function testTableNameUsesSchemaErp24(): void
+ {
+ $this->assertStringContainsString('erp24.suppliers', Supplier::tableName());
+ }
+
+ public function testBadgeMethodsExist(): void
+ {
+ // Сам рендер badge требует установки атрибута AR → DB schema.
+ // Здесь проверяем только наличие методов.
+ $this->assertTrue(method_exists(Supplier::class, 'getTypeBadge'));
+ $this->assertTrue(method_exists(Supplier::class, 'getStatusBadge'));
+ }
+
+ public function testAttributeLabelsContainKeyFields(): void
+ {
+ $labels = (new Supplier())->attributeLabels();
+ foreach (['name', 'type', 'currency', 'lead_time_days', 'is_active'] as $field) {
+ $this->assertArrayHasKey($field, $labels);
+ $this->assertNotEmpty($labels[$field]);
+ }
+ }
+
+ public function testRulesContainRequiredFields(): void
+ {
+ $rules = (new Supplier())->rules();
+ $requiredRule = null;
+ foreach ($rules as $rule) {
+ if (isset($rule[1]) && $rule[1] === 'required') {
+ $requiredRule = $rule[0];
+ break;
+ }
+ }
+ $this->assertNotNull($requiredRule, 'Должно быть правило "required"');
+ $this->assertContains('name', $requiredRule);
+ $this->assertContains('type', $requiredRule);
+ $this->assertContains('currency', $requiredRule);
+ }
+
+ public function testDeactivateMethodExists(): void
+ {
+ $this->assertTrue(method_exists(Supplier::class, 'deactivate'));
+ }
+
+ public function testRelationsMethodsExist(): void
+ {
+ $this->assertTrue(method_exists(Supplier::class, 'getMarkings'));
+ $this->assertTrue(method_exists(Supplier::class, 'getProductMappings'));
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace tests\unit\services;
+
+use Codeception\Test\Unit;
+use ReflectionClass;
+use yii_app\services\ProductMappingService;
+
+/**
+ * Unit-тесты структуры ProductMappingService (ERP-322, ERP-323).
+ *
+ * Без DB — проверяет наличие всех публичных методов и константу кеша.
+ * Реальная логика проверяется вручную на dev-окружении.
+ *
+ * @group erp300
+ * @group product-mapping
+ * @covers \yii_app\services\ProductMappingService
+ */
+class ProductMappingServiceTest extends Unit
+{
+ public function testCacheDurationIsFiveMinutes(): void
+ {
+ $this->assertSame(300, ProductMappingService::CACHE_DURATION);
+ }
+
+ public function testGetProductsWithMappingsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'getProductsWithMappings'));
+ }
+
+ public function testGetAnalyticsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'getAnalytics'));
+ }
+
+ public function testGetCascadeFiltersMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'getCascadeFilters'));
+ }
+
+ public function testGetFolders1cMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'getFolders1c'));
+ }
+
+ public function testExportToXlsxMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'exportToXlsx'));
+ }
+
+ public function testFindOrphanMappingsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'findOrphanMappings'));
+ }
+
+ public function testDeleteOrphanMappingsMethodExists(): void
+ {
+ $this->assertTrue(method_exists(ProductMappingService::class, 'deleteOrphanMappings'));
+ }
+
+ public function testBuildFilteredQueryIsPrivate(): void
+ {
+ $reflection = new ReflectionClass(ProductMappingService::class);
+ $this->assertTrue($reflection->hasMethod('buildFilteredQuery'));
+ $this->assertTrue($reflection->getMethod('buildFilteredQuery')->isPrivate());
+ }
+
+ public function testLoadMappingsForProductsIsPrivate(): void
+ {
+ $reflection = new ReflectionClass(ProductMappingService::class);
+ $this->assertTrue($reflection->hasMethod('loadMappingsForProducts'));
+ $this->assertTrue($reflection->getMethod('loadMappingsForProducts')->isPrivate());
+ }
+
+ public function testClassNamespace(): void
+ {
+ $reflection = new ReflectionClass(ProductMappingService::class);
+ $this->assertSame('yii_app\services', $reflection->getNamespaceName());
+ }
+}
$this->title = 'Справочник закупщика';
?>
-<h1 class="ms-3 mb-4"><?= Html::encode($this->title) ?></h1>
+<h1 class="px-4 mb-4"><?= Html::encode($this->title) ?></h1>
-<div class="px-3" id="buyer-reference-container">
+<style>
+ #buyerRefTabs .nav-link {
+ color: #1e3a5f;
+ }
+ #buyerRefTabs .nav-link:not(.active):hover,
+ #buyerRefTabs .nav-link:not(.active):focus {
+ color: #1e3a5f;
+ background-color: #e9ecef;
+ border-color: #dee2e6 #dee2e6 #fff;
+ }
+ #buyerRefTabs .nav-link.active,
+ #buyerRefTabs .nav-link.active:hover,
+ #buyerRefTabs .nav-link.active:focus {
+ color: #fff;
+ font-weight: 600;
+ background-color: #6f42c1;
+ border-color: #6f42c1 #6f42c1 #6f42c1;
+ }
+</style>
+
+<div class="px-4" id="buyer-reference-container">
<ul class="nav nav-tabs" id="buyerRefTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-suppliers-btn" data-bs-toggle="tab"
data-bs-target="#tab-suppliers" type="button" role="tab"
aria-controls="tab-suppliers" aria-selected="true">
- <i class="fas fa-building me-1"></i>Поставщики
+ <i class="fa fa-building me-1"></i>Поставщики
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-producers-btn" data-bs-toggle="tab"
data-bs-target="#tab-producers" type="button" role="tab"
aria-controls="tab-producers" aria-selected="false">
- <i class="fas fa-seedling me-1"></i>Производители
+ <i class="fa fa-leaf me-1"></i>Производители
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-markings-btn" data-bs-toggle="tab"
data-bs-target="#tab-markings" type="button" role="tab"
aria-controls="tab-markings" aria-selected="false">
- <i class="fas fa-barcode me-1"></i>Маркировка
+ <i class="fa fa-barcode me-1"></i>Маркировка
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-mappings-btn" data-bs-toggle="tab"
data-bs-target="#tab-mappings" type="button" role="tab"
aria-controls="tab-mappings" aria-selected="false">
- <i class="fas fa-link me-1"></i>Маппинг
+ <i class="fa fa-link me-1"></i>Маппинг
</button>
</li>
</ul>
<div class="tab-content mt-3" id="buyerRefTabContent">
<div class="tab-pane fade show active" id="tab-suppliers" role="tabpanel" aria-labelledby="tab-suppliers-btn">
<div class="text-muted p-4 text-center">
- <i class="fas fa-spinner fa-spin me-2"></i>Загрузка справочника поставщиков...
+ <i class="fa fa-spinner fa-spin me-2"></i>Загрузка справочника поставщиков...
</div>
</div>
<div class="tab-pane fade" id="tab-producers" role="tabpanel" aria-labelledby="tab-producers-btn">
<div class="text-muted p-4 text-center">
- <i class="fas fa-spinner fa-spin me-2"></i>Загрузка производителей и плантаций...
+ <i class="fa fa-spinner fa-spin me-2"></i>Загрузка производителей и плантаций...
</div>
</div>
<div class="tab-pane fade" id="tab-markings" role="tabpanel" aria-labelledby="tab-markings-btn">
<div class="text-muted p-4 text-center">
- <i class="fas fa-spinner fa-spin me-2"></i>Загрузка маркировок...
+ <i class="fa fa-spinner fa-spin me-2"></i>Загрузка маркировок...
</div>
</div>
<div class="tab-pane fade" id="tab-mappings" role="tabpanel" aria-labelledby="tab-mappings-btn">
<div class="text-muted p-4 text-center">
- <i class="fas fa-spinner fa-spin me-2"></i>Загрузка маппинга товаров...
+ <i class="fa fa-spinner fa-spin me-2"></i>Загрузка маппинга товаров...
</div>
</div>
</div>
use yii\helpers\Url;
$supplierIndexUrl = Url::to(['/supplier/index']);
+$producerIndexUrl = Url::to(['/producer/index']);
+$markingIndexUrl = Url::to(['/marking/index']);
+$mappingIndexUrl = Url::to(['/product-mapping/index']);
$js = <<<JS
(function() {
function loadTab(tabId, url) {
var \$pane = $('#' + tabId);
- if (\$pane.data('loaded')) return;
- $.get(url, function(html) {
+ if (\$pane.data('loaded') || \$pane.data('loading')) return;
+ \$pane.data('loading', true);
+ \$pane.html('<div class="text-muted p-4 text-center"><i class="fa fa-spinner fa-spin me-2"></i>Загрузка...</div>');
+ $.ajax({
+ url: url,
+ type: 'GET',
+ timeout: 30000,
+ dataType: 'html'
+ }).done(function(html) {
\$pane.html(html);
\$pane.data('loaded', true);
+ }).fail(function(xhr, status) {
+ var code = xhr.status || 0;
+ var msg = 'Ошибка загрузки вкладки';
+ if (code === 403) {
+ msg = 'Нет прав доступа (403). Обратитесь к администратору — требуется permission для этого раздела.';
+ } else if (code === 404) {
+ msg = 'Раздел не найден (404).';
+ } else if (code >= 500) {
+ msg = 'Ошибка сервера (' + code + '). Проверьте логи приложения.';
+ } else if (status === 'timeout') {
+ msg = 'Превышено время ожидания (30 сек).';
+ } else if (code) {
+ msg = 'Ошибка загрузки (' + code + ').';
+ }
+ \$pane.html(
+ '<div class="alert alert-danger m-3">' +
+ '<i class="fa fa-exclamation-triangle me-2"></i>' + msg +
+ ' <button type="button" class="btn btn-sm btn-outline-danger ms-2 btn-tab-retry">Повторить</button>' +
+ '</div>'
+ );
+ \$pane.find('.btn-tab-retry').on('click', function() {
+ \$pane.removeData('loaded');
+ loadTab(tabId, url);
+ });
+ }).always(function() {
+ \$pane.removeData('loading');
});
}
- // Загрузка вкладки поставщиков сразу (она active по умолчанию)
+ var tabMap = {
+ 'tab-suppliers-btn': ['tab-suppliers', '{$supplierIndexUrl}'],
+ 'tab-producers-btn': ['tab-producers', '{$producerIndexUrl}'],
+ 'tab-markings-btn': ['tab-markings', '{$markingIndexUrl}'],
+ 'tab-mappings-btn': ['tab-mappings', '{$mappingIndexUrl}']
+ };
+
+ // Загрузка активной вкладки сразу
loadTab('tab-suppliers', '{$supplierIndexUrl}');
- // Загрузка при переключении вкладок
- $('#tab-suppliers-btn').on('shown.bs.tab', function() {
- loadTab('tab-suppliers', '{$supplierIndexUrl}');
+ // Клик по кнопке вкладки — прямой обработчик, не зависит от bootstrap events
+ $('#buyerRefTabs').on('click', '.nav-link', function() {
+ var cfg = tabMap[this.id];
+ if (cfg) {
+ loadTab(cfg[0], cfg[1]);
+ }
+ });
+
+ // Резервный триггер на bootstrap-события — если клик пришёл не через нашу кнопку
+ $('#buyerRefTabs').on('show.bs.tab shown.bs.tab', '.nav-link', function() {
+ var cfg = tabMap[this.id];
+ if (cfg) {
+ loadTab(cfg[0], cfg[1]);
+ }
});
})();
JS;
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+
+/** @var yii_app\records\Marking $model */
+/** @var array<int,string> $producers */
+/** @var array<int,string> $plantations */
+/** @var array<int,string> $suppliers */
+?>
+
+<form id="marking-form">
+ <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?= Yii::$app->request->csrfToken ?>">
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Код *</label>
+ <input type="text" class="form-control form-control-sm marking-code-input" name="Marking[code]"
+ value="<?= Html::encode($model->code) ?>" maxlength="12" minlength="3"
+ pattern="[A-Z0-9\-]{3,12}"
+ placeholder="SCH-RP-RN50"
+ style="font-family:monospace;text-transform:uppercase;letter-spacing:0.5px;"
+ required autofocus>
+ <div class="form-text" style="font-size:10px;">3-12 символов: A-Z, 0-9, дефис</div>
+ </div>
+
+ <div class="d-flex gap-2 mb-2">
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Производитель *</label>
+ <select class="form-select form-select-sm marking-producer-select" name="Marking[producer_id]" required>
+ <option value="">— выберите —</option>
+ <?php foreach ($producers as $id => $name): ?>
+ <option value="<?= (int)$id ?>" <?= ((int)$model->producer_id === (int)$id) ? 'selected' : '' ?>>
+ <?= Html::encode($name) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Плантация *</label>
+ <select class="form-select form-select-sm marking-plantation-select" name="Marking[plantation_id]" required>
+ <option value="">— выберите производителя —</option>
+ <?php foreach ($plantations as $id => $name): ?>
+ <option value="<?= (int)$id ?>" <?= ((int)$model->plantation_id === (int)$id) ? 'selected' : '' ?>>
+ <?= Html::encode($name) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Название товара *</label>
+ <input type="text" class="form-control form-control-sm" name="Marking[product_name]"
+ value="<?= Html::encode($model->product_name) ?>" maxlength="200"
+ placeholder="Red Naomi 50см" required>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Поставщик *</label>
+ <select class="form-select form-select-sm marking-supplier-select" name="Marking[supplier_id]" required>
+ <option value="">— выберите —</option>
+ <?php foreach ($suppliers as $id => $name): ?>
+ <option value="<?= (int)$id ?>" <?= ((int)$model->supplier_id === (int)$id) ? 'selected' : '' ?>>
+ <?= Html::encode($name) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="border-top pt-2 mt-3" style="background:#f3e9fa;border-radius:4px;padding:8px;">
+ <div style="font-size:10px;color:#6c757d;font-weight:600;margin-bottom:4px;">ПРЕВЬЮ</div>
+ <div id="marking-preview" style="font-size:12px;color:#4a1d8e;font-family:monospace;">
+ —
+ </div>
+ </div>
+
+ <div class="d-flex justify-content-end gap-2 pt-2 border-top mt-3">
+ <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+ <button type="button" class="btn btn-sm" id="btn-marking-save" style="background:#4a1d8e;color:#fff;">
+ <i class="fa fa-save me-1"></i>Сохранить
+ </button>
+ </div>
+</form>
+
+<script>
+(function() {
+ // Uppercase код при вводе
+ $('.marking-code-input').on('input', function() {
+ this.value = this.value.toUpperCase();
+ updatePreview();
+ });
+
+ // Cascade плантаций при смене производителя
+ $('.marking-producer-select').on('change', function() {
+ var producerId = $(this).val();
+ var $plantationSelect = $('.marking-plantation-select');
+
+ if (!producerId) {
+ $plantationSelect.html('<option value="">— выберите производителя —</option>');
+ updatePreview();
+ return;
+ }
+
+ $plantationSelect.html('<option value="">Загрузка...</option>');
+
+ $.get('/marking/plantations-by-producer', {producer_id: producerId}, function(resp) {
+ if (resp.success) {
+ var html = '<option value="">— выберите —</option>';
+ $.each(resp.items, function(_, p) {
+ html += '<option value="' + p.id + '">' + p.name + ' (' + p.country + ')</option>';
+ });
+ $plantationSelect.html(html);
+ } else {
+ $plantationSelect.html('<option value="">Ошибка загрузки</option>');
+ }
+ updatePreview();
+ }, 'json');
+ });
+
+ // Превью
+ function updatePreview() {
+ var code = $('.marking-code-input').val() || '???';
+ var producer = $('.marking-producer-select option:selected').text().trim();
+ var plantation = $('.marking-plantation-select option:selected').text().trim();
+ var productName = $('[name="Marking[product_name]"]').val() || '???';
+ var supplier = $('.marking-supplier-select option:selected').text().trim();
+
+ if (producer.indexOf('—') === 0 || !producer) producer = '???';
+ if (plantation.indexOf('—') === 0 || !plantation) plantation = '???';
+ if (supplier.indexOf('—') === 0 || !supplier) supplier = '???';
+
+ $('#marking-preview').text(code + ' → ' + producer + ' / ' + plantation + ' / ' + productName + ' / ' + supplier);
+ }
+
+ $('#marking-form').on('input change', 'input, select', updatePreview);
+ updatePreview();
+})();
+</script>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\widgets\Pjax;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\MarkingSearch $searchModel */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+
+/** @var yii_app\records\Marking[] $markings */
+$markings = $dataProvider->getModels();
+
+// Предзагрузим количество маппингов для всех маркировок одним запросом
+$mappingsCounts = [];
+if (!empty($markings)) {
+ $ids = array_map(static fn($m) => (int)$m->id, $markings);
+ $rows = (new \yii\db\Query())
+ ->select(['marking_id', 'cnt' => 'COUNT(*)'])
+ ->from('{{%erp24.mapping_markings}}')
+ ->where(['marking_id' => $ids])
+ ->groupBy('marking_id')
+ ->all();
+ foreach ($rows as $row) {
+ $mappingsCounts[(int)$row['marking_id']] = (int)$row['cnt'];
+ }
+}
+?>
+
+<style>
+ .badge-status-active { background:#d1e7dd; color:#0f5132; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .badge-status-inactive { background:#e2e3e5; color:#41464b; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .marking-code { background:#4a1d8e; color:#fff; font-family:monospace; font-size:10px; padding:2px 6px; border-radius:3px; display:inline-block; letter-spacing:0.5px; font-weight:600; }
+ .badge-unlinked { background:#ffc107; color:#664d03; font-size:9px; padding:1px 5px; border-radius:3px; display:inline-block; margin-left:6px; }
+ .badge-mappings-count { background:#e7e2f0; color:#4a1d8e; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; font-weight:600; }
+ .row-unlinked td { background:#fff3cd !important; }
+ .row-inactive { opacity:0.5; }
+</style>
+
+<div class="d-flex justify-content-between align-items-center mb-3">
+ <div>
+ <strong class="fs-5" style="color:#4a1d8e;">
+ <i class="fa fa-barcode me-1"></i>Справочник маркировок
+ </strong>
+ <br><span class="text-muted" style="font-size:12px;">Уникальные коды товаров у производителей</span>
+ </div>
+ <button class="btn btn-sm" id="btn-marking-create" style="background:#4a1d8e;color:#fff;">
+ <i class="fa fa-plus me-1"></i>Добавить маркировку
+ </button>
+</div>
+
+<?php Pjax::begin(['id' => 'marking-pjax', 'timeout' => 5000]); ?>
+
+<table class="table table-bordered table-hover table-sm">
+ <thead>
+ <tr>
+ <th style="width:120px;">Код</th>
+ <th>Производитель</th>
+ <th>Плантация</th>
+ <th>Название</th>
+ <th>Поставщик</th>
+ <th style="text-align:center;width:100px;">Маппингов</th>
+ <th style="text-align:center;width:100px;">Статус</th>
+ <th style="text-align:center;width:80px;">Действия</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($markings)): ?>
+ <tr>
+ <td colspan="8" class="text-center text-muted p-4">
+ <i class="fa fa-inbox me-1"></i>Пока нет ни одной маркировки
+ </td>
+ </tr>
+ <?php endif; ?>
+
+ <?php foreach ($markings as $marking): ?>
+ <?php
+ $mappingsCount = $mappingsCounts[(int)$marking->id] ?? 0;
+ $isUnlinked = $marking->is_active && $mappingsCount === 0;
+ $rowClasses = [];
+ if (!$marking->is_active) {
+ $rowClasses[] = 'row-inactive';
+ } elseif ($isUnlinked) {
+ $rowClasses[] = 'row-unlinked';
+ }
+ ?>
+ <tr class="<?= implode(' ', $rowClasses) ?>">
+ <td>
+ <span class="marking-code"><?= Html::encode($marking->code) ?></span>
+ </td>
+ <td><?= Html::encode($marking->producer?->name ?? '—') ?></td>
+ <td><?= Html::encode($marking->plantation?->name ?? '—') ?></td>
+ <td>
+ <?= Html::encode($marking->product_name) ?>
+ <?php if ($isUnlinked): ?>
+ <span class="badge-unlinked">Не привязана</span>
+ <?php endif; ?>
+ </td>
+ <td><?= Html::encode($marking->supplier?->name ?? '—') ?></td>
+ <td style="text-align:center;">
+ <?php if ($mappingsCount > 0): ?>
+ <span class="badge-mappings-count"><?= $mappingsCount ?></span>
+ <?php else: ?>
+ <span class="text-muted">0</span>
+ <?php endif; ?>
+ </td>
+ <td style="text-align:center;">
+ <?= $marking->getStatusBadge() ?>
+ </td>
+ <td style="text-align:center;white-space:nowrap;">
+ <a href="#" class="btn-marking-edit" data-id="<?= (int)$marking->id ?>" title="Редактировать">
+ <i class="fa fa-pencil text-secondary"></i>
+ </a>
+ <?php if ($marking->is_active): ?>
+ <a href="#" class="btn-marking-delete ms-1"
+ data-id="<?= (int)$marking->id ?>"
+ data-code="<?= Html::encode($marking->code) ?>"
+ title="Деактивировать">
+ <i class="fa fa-trash text-danger"></i>
+ </a>
+ <?php else: ?>
+ <i class="fa fa-trash text-secondary ms-1" style="opacity:0.3;"></i>
+ <?php endif; ?>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+</table>
+
+<?php Pjax::end(); ?>
+
+<!-- Модальное окно -->
+<div class="modal fade" id="marking-modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header" style="background:#4a1d8e;color:#fff;">
+ <h5 class="modal-title">
+ <i class="fa fa-barcode me-1"></i>
+ <span id="marking-modal-title">Добавить маркировку</span>
+ </h5>
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
+ </div>
+ <div class="modal-body" id="marking-modal-body"></div>
+ </div>
+ </div>
+</div>
+
+<?php
+$reloadUrl = Url::to(['/marking/index']);
+$createFormUrl = Url::to(['/marking/create-form']);
+$updateFormUrl = Url::to(['/marking/update-form']);
+$createUrl = Url::to(['/marking/create']);
+$updateUrl = Url::to(['/marking/update']);
+$deleteUrl = Url::to(['/marking/delete']);
+
+$js = <<<JS
+(function() {
+ var markingModal = new bootstrap.Modal(document.getElementById('marking-modal'));
+ var editingId = null;
+ var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
+
+ function reloadMarkingTab() {
+ $.get('{$reloadUrl}', function(html) {
+ $('#tab-markings').html(html);
+ }).fail(function(xhr) {
+ alert('Ошибка обновления списка маркировок (' + (xhr.status || '?') + '). Обновите страницу вручную.');
+ });
+ }
+
+ $('#btn-marking-create').on('click', function() {
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingId = null;
+ $('#marking-modal-title').text('Добавить маркировку');
+ $('#marking-modal-body').html(loaderHtml);
+ markingModal.show();
+ $.get('{$createFormUrl}', function(html) {
+ $('#marking-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '.btn-marking-edit', function(e) {
+ e.preventDefault();
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingId = \$btn.data('id');
+ $('#marking-modal-title').text('Редактировать маркировку');
+ $('#marking-modal-body').html(loaderHtml);
+ markingModal.show();
+ $.get('{$updateFormUrl}', {id: editingId}, function(html) {
+ $('#marking-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '#btn-marking-save', function() {
+ var \$form = $('#marking-form');
+ var url = editingId ? '{$updateUrl}?id=' + editingId : '{$createUrl}';
+
+ \$form.find('.is-invalid').removeClass('is-invalid');
+ \$form.find('.invalid-feedback').remove();
+
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: \$form.serialize(),
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ markingModal.hide();
+ reloadMarkingTab();
+ } else if (resp.errors) {
+ $.each(resp.errors, function(field, messages) {
+ var \$input = \$form.find('[name="Marking[' + field + ']"]');
+ \$input.addClass('is-invalid');
+ \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ $(document).on('click', '.btn-marking-delete', function(e) {
+ e.preventDefault();
+ var id = $(this).data('id');
+ var code = $(this).data('code');
+
+ if (!confirm('Деактивировать маркировку "' + code + '"?')) {
+ return;
+ }
+
+ $.ajax({
+ url: '{$deleteUrl}?id=' + id,
+ type: 'POST',
+ data: {_csrf: yii.getCsrfToken()},
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ reloadMarkingTab();
+ } else {
+ alert(resp.message || 'Ошибка деактивации');
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ // Очистка ошибок при вводе
+ $(document).on('input change', '#marking-form input, #marking-form select', function() {
+ $(this).removeClass('is-invalid');
+ $(this).next('.invalid-feedback').remove();
+ });
+})();
+JS;
+
+$this->registerJs($js);
+?>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii_app\records\Plantation;
+
+/** @var yii_app\records\Plantation $model */
+/** @var array<int,string> $producers */
+?>
+
+<form id="plantation-form">
+ <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?= Yii::$app->request->csrfToken ?>">
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Производитель *</label>
+ <select class="form-select form-select-sm" name="Plantation[producer_id]" required>
+ <option value="">— выберите —</option>
+ <?php foreach ($producers as $id => $name): ?>
+ <option value="<?= (int)$id ?>" <?= ((int)$model->producer_id === (int)$id) ? 'selected' : '' ?>>
+ <?= Html::encode($name) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Название *</label>
+ <input type="text" class="form-control form-control-sm" name="Plantation[name]"
+ value="<?= Html::encode($model->name) ?>" maxlength="200"
+ placeholder="Finca Las Rosas" required>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Страна *</label>
+ <select class="form-select form-select-sm" name="Plantation[country]" required>
+ <option value="">— выберите —</option>
+ <?php foreach (Plantation::getCountryOptions() as $code => $label): ?>
+ <option value="<?= Html::encode($code) ?>" <?= $model->country === $code ? 'selected' : '' ?>>
+ <?= Html::encode($label) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="d-flex justify-content-end gap-2 pt-2 border-top mt-3">
+ <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+ <button type="button" class="btn btn-primary btn-sm" id="btn-plantation-save">
+ <i class="fa fa-save me-1"></i>Сохранить
+ </button>
+ </div>
+</form>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+
+/** @var yii_app\records\Producer $model */
+?>
+
+<form id="producer-form">
+ <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?= Yii::$app->request->csrfToken ?>">
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Название *</label>
+ <input type="text" class="form-control form-control-sm" name="Producer[name]"
+ value="<?= Html::encode($model->name) ?>" maxlength="200"
+ placeholder="Selecta One" required autofocus>
+ </div>
+
+ <div class="d-flex justify-content-end gap-2 pt-2 border-top mt-3">
+ <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+ <button type="button" class="btn btn-primary btn-sm" id="btn-producer-save">
+ <i class="fa fa-save me-1"></i>Сохранить
+ </button>
+ </div>
+</form>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\widgets\Pjax;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\ProducerSearch $searchModel */
+/** @var yii\data\ActiveDataProvider $dataProvider */
+
+/** @var yii_app\records\Producer[] $producers */
+$producers = $dataProvider->getModels();
+?>
+
+<style>
+ .badge-status-active { background:#d1e7dd; color:#0f5132; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .badge-status-inactive { background:#e2e3e5; color:#41464b; font-size:10px; padding:1px 6px; border-radius:3px; display:inline-block; }
+ .producer-row td { background:#f8f9fa; }
+ .producer-row .producer-name { font-weight:600; color:#1e3a5f; }
+ .plantation-row td:first-child { padding-left:24px; color:#6c757d; }
+ .row-inactive { opacity:0.5; }
+</style>
+
+<div class="d-flex justify-content-between align-items-center mb-3">
+ <div>
+ <strong class="fs-5" style="color:#1e3a5f;">
+ <i class="fa fa-leaf me-1"></i>Производители и плантации
+ </strong>
+ <br><span class="text-muted" style="font-size:12px;">Селекционеры/бренды и их фермы</span>
+ </div>
+ <div class="d-flex gap-2">
+ <button class="btn btn-outline-primary btn-sm" id="btn-producer-create">
+ <i class="fa fa-plus me-1"></i>Добавить производителя
+ </button>
+ <button class="btn btn-primary btn-sm" id="btn-plantation-create">
+ <i class="fa fa-plus me-1"></i>Добавить плантацию
+ </button>
+ </div>
+</div>
+
+<?php Pjax::begin(['id' => 'producer-pjax', 'timeout' => 5000]); ?>
+
+<table class="table table-bordered table-hover table-sm">
+ <thead>
+ <tr>
+ <th>Производитель</th>
+ <th>Плантация</th>
+ <th style="text-align:center;width:140px;">Страна</th>
+ <th style="text-align:center;width:100px;">Статус</th>
+ <th style="text-align:center;width:80px;">Действия</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php if (empty($producers)): ?>
+ <tr>
+ <td colspan="5" class="text-center text-muted p-4">
+ <i class="fa fa-inbox me-1"></i>Пока нет ни одного производителя
+ </td>
+ </tr>
+ <?php endif; ?>
+
+ <?php foreach ($producers as $producer): ?>
+ <?php /** @var yii_app\records\Producer $producer */ ?>
+ <tr class="producer-row <?= $producer->is_active ? '' : 'row-inactive' ?>">
+ <td class="producer-name">
+ <i class="fa fa-industry me-1 text-secondary"></i>
+ <?= Html::encode($producer->name) ?>
+ </td>
+ <td class="text-muted fst-italic" colspan="2" style="font-size:11px;">
+ <?= count($producer->plantations) ?> плантаций
+ </td>
+ <td style="text-align:center;">
+ <?= $producer->getStatusBadge() ?>
+ </td>
+ <td style="text-align:center;white-space:nowrap;">
+ <a href="#" class="btn-producer-edit" data-id="<?= (int)$producer->id ?>" title="Редактировать производителя">
+ <i class="fa fa-pencil text-secondary"></i>
+ </a>
+ <?php if ($producer->is_active): ?>
+ <a href="#" class="btn-producer-delete ms-1"
+ data-id="<?= (int)$producer->id ?>"
+ data-name="<?= Html::encode($producer->name) ?>"
+ title="Деактивировать производителя">
+ <i class="fa fa-trash text-danger"></i>
+ </a>
+ <?php else: ?>
+ <i class="fa fa-trash text-secondary ms-1" style="opacity:0.3;"></i>
+ <?php endif; ?>
+ </td>
+ </tr>
+
+ <?php foreach ($producer->plantations as $plantation): ?>
+ <?php /** @var yii_app\records\Plantation $plantation */ ?>
+ <tr class="plantation-row <?= ($producer->is_active && $plantation->is_active) ? '' : 'row-inactive' ?>">
+ <td></td>
+ <td>
+ <i class="fa fa-leaf me-1 text-success" style="font-size:10px;"></i>
+ <?= Html::encode($plantation->name) ?>
+ </td>
+ <td style="text-align:center;">
+ <?= Html::encode($plantation->country) ?>
+ </td>
+ <td style="text-align:center;">
+ <?= $plantation->getStatusBadge() ?>
+ </td>
+ <td style="text-align:center;white-space:nowrap;">
+ <a href="#" class="btn-plantation-edit" data-id="<?= (int)$plantation->id ?>" title="Редактировать плантацию">
+ <i class="fa fa-pencil text-secondary"></i>
+ </a>
+ <?php if ($plantation->is_active): ?>
+ <a href="#" class="btn-plantation-delete ms-1"
+ data-id="<?= (int)$plantation->id ?>"
+ data-name="<?= Html::encode($plantation->name) ?>"
+ title="Деактивировать плантацию">
+ <i class="fa fa-trash text-danger"></i>
+ </a>
+ <?php else: ?>
+ <i class="fa fa-trash text-secondary ms-1" style="opacity:0.3;"></i>
+ <?php endif; ?>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ <?php endforeach; ?>
+ </tbody>
+</table>
+
+<?php Pjax::end(); ?>
+
+<!-- Модальное окно (универсальное: для producer и plantation) -->
+<div class="modal fade" id="producer-modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header" style="background:#1e3a5f;color:#fff;">
+ <h5 class="modal-title">
+ <i class="fa fa-leaf me-1"></i>
+ <span id="producer-modal-title">Добавить</span>
+ </h5>
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
+ </div>
+ <div class="modal-body" id="producer-modal-body"></div>
+ </div>
+ </div>
+</div>
+
+<?php
+$reloadUrl = Url::to(['/producer/index']);
+$createProducerFormUrl = Url::to(['/producer/create-producer-form']);
+$updateProducerFormUrl = Url::to(['/producer/update-producer-form']);
+$createProducerUrl = Url::to(['/producer/create-producer']);
+$updateProducerUrl = Url::to(['/producer/update-producer']);
+$deleteProducerUrl = Url::to(['/producer/delete-producer']);
+
+$createPlantationFormUrl = Url::to(['/producer/create-plantation-form']);
+$updatePlantationFormUrl = Url::to(['/producer/update-plantation-form']);
+$createPlantationUrl = Url::to(['/producer/create-plantation']);
+$updatePlantationUrl = Url::to(['/producer/update-plantation']);
+$deletePlantationUrl = Url::to(['/producer/delete-plantation']);
+
+$js = <<<JS
+(function() {
+ function reloadProducerTab() {
+ $.get('{$reloadUrl}', function(html) {
+ // Заменяем содержимое вкладки целиком свежей копией (вместе с pjax, модалкой и JS).
+ $('#tab-producers').html(html);
+ });
+ }
+
+ var producerModal = new bootstrap.Modal(document.getElementById('producer-modal'));
+ var editingProducerId = null;
+ var editingPlantationId = null;
+ var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
+
+ // ========== PRODUCER ==========
+
+ $('#btn-producer-create').on('click', function() {
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingProducerId = null;
+ $('#producer-modal-title').text('Добавить производителя');
+ $('#producer-modal-body').html(loaderHtml);
+ producerModal.show();
+ $.get('{$createProducerFormUrl}', function(html) {
+ $('#producer-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '.btn-producer-edit', function(e) {
+ e.preventDefault();
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingProducerId = \$btn.data('id');
+ $('#producer-modal-title').text('Редактировать производителя');
+ $('#producer-modal-body').html(loaderHtml);
+ producerModal.show();
+ $.get('{$updateProducerFormUrl}', {id: editingProducerId}, function(html) {
+ $('#producer-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '#btn-producer-save', function() {
+ var \$form = $('#producer-form');
+ var url = editingProducerId ? '{$updateProducerUrl}?id=' + editingProducerId : '{$createProducerUrl}';
+
+ \$form.find('.is-invalid').removeClass('is-invalid');
+ \$form.find('.invalid-feedback').remove();
+
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: \$form.serialize(),
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ producerModal.hide();
+ reloadProducerTab();
+ } else if (resp.errors) {
+ $.each(resp.errors, function(field, messages) {
+ var \$input = \$form.find('[name="Producer[' + field + ']"]');
+ \$input.addClass('is-invalid');
+ \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ $(document).on('click', '.btn-producer-delete', function(e) {
+ e.preventDefault();
+ var id = $(this).data('id');
+ var name = $(this).data('name');
+
+ if (!confirm('Деактивировать производителя "' + name + '"?\\nВсе его плантации также будут деактивированы.')) {
+ return;
+ }
+
+ $.ajax({
+ url: '{$deleteProducerUrl}?id=' + id,
+ type: 'POST',
+ data: {_csrf: yii.getCsrfToken()},
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ reloadProducerTab();
+ } else {
+ alert(resp.message || 'Ошибка деактивации');
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ // ========== PLANTATION ==========
+
+ $('#btn-plantation-create').on('click', function() {
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingPlantationId = null;
+ $('#producer-modal-title').text('Добавить плантацию');
+ $('#producer-modal-body').html(loaderHtml);
+ producerModal.show();
+ $.get('{$createPlantationFormUrl}', function(html) {
+ $('#producer-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '.btn-plantation-edit', function(e) {
+ e.preventDefault();
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingPlantationId = \$btn.data('id');
+ $('#producer-modal-title').text('Редактировать плантацию');
+ $('#producer-modal-body').html(loaderHtml);
+ producerModal.show();
+ $.get('{$updatePlantationFormUrl}', {id: editingPlantationId}, function(html) {
+ $('#producer-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '#btn-plantation-save', function() {
+ var \$form = $('#plantation-form');
+ var url = editingPlantationId ? '{$updatePlantationUrl}?id=' + editingPlantationId : '{$createPlantationUrl}';
+
+ \$form.find('.is-invalid').removeClass('is-invalid');
+ \$form.find('.invalid-feedback').remove();
+
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: \$form.serialize(),
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ producerModal.hide();
+ reloadProducerTab();
+ } else if (resp.errors) {
+ $.each(resp.errors, function(field, messages) {
+ var \$input = \$form.find('[name="Plantation[' + field + ']"]');
+ \$input.addClass('is-invalid');
+ \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ $(document).on('click', '.btn-plantation-delete', function(e) {
+ e.preventDefault();
+ var id = $(this).data('id');
+ var name = $(this).data('name');
+
+ if (!confirm('Деактивировать плантацию "' + name + '"?')) {
+ return;
+ }
+
+ $.ajax({
+ url: '{$deletePlantationUrl}?id=' + id,
+ type: 'POST',
+ data: {_csrf: yii.getCsrfToken()},
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ reloadProducerTab();
+ } else {
+ alert(resp.message || 'Ошибка деактивации');
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ // Очистка ошибок при вводе
+ $(document).on('input change', '#producer-form input, #producer-form select, #plantation-form input, #plantation-form select', function() {
+ $(this).removeClass('is-invalid');
+ $(this).next('.invalid-feedback').remove();
+ });
+})();
+JS;
+
+$this->registerJs($js);
+?>
--- /dev/null
+<?php
+
+/** @var array $analytics */
+?>
+
+<div class="row g-2 mb-3" id="pm-analytics">
+ <div class="col-md-3">
+ <div class="card border-0" style="background:#fff;border:1px solid #dee2e6 !important;border-radius:6px;">
+ <div class="card-body p-2">
+ <div class="text-muted" style="font-size:10px;text-transform:uppercase;">Актуальных</div>
+ <div style="font-size:22px;font-weight:700;color:#1e3a5f;" data-analytics-field="total_actual">
+ <?= (int)$analytics['total_actual'] ?>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="card border-0" style="background:#fff;border:1px solid #dee2e6 !important;border-left:4px solid #198754 !important;border-radius:6px;">
+ <div class="card-body p-2">
+ <div class="text-muted" style="font-size:10px;text-transform:uppercase;">С поставщиком</div>
+ <div style="font-size:22px;font-weight:700;color:#198754;">
+ <span data-analytics-field="with_supplier"><?= (int)$analytics['with_supplier'] ?></span>
+ <span style="font-size:12px;font-weight:400;color:#6c757d;">
+ (<span data-analytics-field="with_supplier_pct"><?= $analytics['with_supplier_pct'] ?></span>%)
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="card border-0" style="background:#fff;border:1px solid #dee2e6 !important;border-left:4px solid #dc3545 !important;border-radius:6px;position:relative;overflow:hidden;">
+ <div class="card-body p-2">
+ <div class="text-muted" style="font-size:10px;text-transform:uppercase;">Без производителя</div>
+ <div style="font-size:22px;font-weight:700;color:#dc3545;">
+ <span data-analytics-field="without_producer"><?= (int)$analytics['without_producer'] ?></span>
+ </div>
+ <div style="position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:24px;font-weight:800;color:#dc3545;opacity:.15;">
+ <span data-analytics-field="without_producer_pct"><?= $analytics['without_producer_pct'] ?></span>%
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-3">
+ <div class="card border-0" style="background:#fff;border:1px solid #dee2e6 !important;border-left:4px solid #ffc107 !important;border-radius:6px;position:relative;overflow:hidden;">
+ <div class="card-body p-2">
+ <div class="text-muted" style="font-size:10px;text-transform:uppercase;">1 поставщик</div>
+ <div style="font-size:22px;font-weight:700;color:#b86d00;">
+ <span data-analytics-field="single_supplier"><?= (int)$analytics['single_supplier'] ?></span>
+ </div>
+ <div style="position:absolute;right:12px;top:50%;transform:translateY(-50%);font-size:24px;font-weight:800;color:#ffc107;opacity:.15;">
+ <span data-analytics-field="single_supplier_pct"><?= $analytics['single_supplier_pct'] ?></span>%
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+
+/** @var yii_app\records\Products1cNomenclature $product */
+/** @var yii_app\records\ProductMapping[] $mappings */
+
+$count = count($mappings);
+$cardClass = 'pm-card';
+$borderStyle = '';
+$stateLabel = '';
+
+if ($count === 0) {
+ $cardClass .= ' pm-card-empty';
+ $borderStyle = 'border:2px solid #dc3545;';
+ $stateLabel = '<span class="pm-badge-danger">Нет поставщика</span>';
+} elseif ($count === 1) {
+ $cardClass .= ' pm-card-single';
+ $borderStyle = 'border:2px solid #ffc107;';
+ $stateLabel = '<span class="pm-badge-warning">Только 1 поставщик</span>';
+} else {
+ $borderStyle = 'border:1px solid #dee2e6;';
+}
+?>
+
+<div class="<?= $cardClass ?> mb-3 p-3" style="<?= $borderStyle ?>border-radius:6px;background:#fff;"
+ data-product-guid="<?= Html::encode($product->id) ?>">
+
+ <div class="d-flex justify-content-between align-items-start mb-2">
+ <div style="flex:1;">
+ <div style="font-weight:600;color:#1e3a5f;font-size:13px;">
+ <i class="fa fa-cube me-1 text-secondary"></i>
+ <?= Html::encode($product->name) ?>
+ <?= $stateLabel ?>
+ </div>
+ <div class="text-muted" style="font-size:10px;">
+ <?= Html::encode($product->category ?? '—') ?>
+ <?php if ($product->subcategory): ?> / <?= Html::encode($product->subcategory) ?><?php endif; ?>
+ <?php if ($product->species): ?> / <?= Html::encode($product->species) ?><?php endif; ?>
+ <?php if ($product->sort): ?> / <?= Html::encode($product->sort) ?><?php endif; ?>
+ <?php if ($product->size): ?> / <?= (int)$product->size ?>см<?php endif; ?>
+ <span style="color:#adb5bd;"> | GUID: <?= Html::encode($product->id) ?></span>
+ </div>
+ </div>
+ <button type="button" class="btn btn-primary btn-sm btn-pm-add"
+ data-product-guid="<?= Html::encode($product->id) ?>">
+ <i class="fa fa-plus me-1"></i>Добавить поставщика
+ </button>
+ </div>
+
+ <?php if ($count === 0): ?>
+ <div class="text-center text-muted p-3" style="font-size:12px;">
+ <i class="fa fa-exclamation-triangle text-danger me-1"></i>
+ Нет маппинга на поставщиков. Добавьте хотя бы одного.
+ </div>
+ <?php else: ?>
+ <table class="table table-sm table-bordered mb-0" style="font-size:11px;">
+ <thead>
+ <tr style="background:#f8f9fa;">
+ <th>Поставщик</th>
+ <th>Название у поставщика</th>
+ <th>Плантация</th>
+ <th style="width:80px;">Артикул</th>
+ <th style="width:110px;">Штрихкод</th>
+ <th style="width:50px;text-align:center;">Квант</th>
+ <th>Маркировки</th>
+ <th style="width:60px;text-align:center;">Действия</th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($mappings as $mapping): ?>
+ <tr>
+ <td><strong><?= Html::encode($mapping->supplier->name ?? '—') ?></strong></td>
+ <td><?= Html::encode($mapping->supplier_product_name) ?></td>
+ <td><?= Html::encode($mapping->plantation->name ?? '—') ?></td>
+ <td><?= Html::encode($mapping->article ?? '') ?></td>
+ <td><?= Html::encode($mapping->barcode ?? '') ?></td>
+ <td style="text-align:center;"><?= (int)$mapping->quant ?></td>
+ <td>
+ <?php foreach ($mapping->markings as $marking): ?>
+ <span class="marking-code" title="<?= Html::encode($marking->product_name) ?>">
+ <?= Html::encode($marking->code) ?>
+ </span>
+ <?php endforeach; ?>
+ </td>
+ <td style="text-align:center;white-space:nowrap;">
+ <a href="#" class="btn-pm-edit" data-id="<?= (int)$mapping->id ?>" title="Редактировать">
+ <i class="fa fa-pencil text-secondary"></i>
+ </a>
+ <a href="#" class="btn-pm-delete ms-1"
+ data-id="<?= (int)$mapping->id ?>"
+ data-name="<?= Html::encode($mapping->supplier->name ?? '') ?>"
+ title="Удалить маппинг">
+ <i class="fa fa-trash text-danger"></i>
+ </a>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php endif; ?>
+</div>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+
+/** @var yii_app\forms\ProductMappingFilterForm $filters */
+/** @var string[] $categories */
+/** @var string[] $subcategories */
+/** @var string[] $speciesList */
+/** @var array<string,string> $folders1c */
+/** @var array<int,string> $suppliers */
+/** @var array<int,string> $markings */
+?>
+
+<div class="card mb-3" id="pm-filters-card">
+ <div class="card-body p-2" style="font-size:11px;">
+ <div class="row g-2">
+ <div class="col-md-2">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Категория</label>
+ <select class="form-select form-select-sm pm-filter" name="category" id="pm-filter-category">
+ <option value="">Все</option>
+ <?php foreach ($categories as $cat): ?>
+ <option value="<?= Html::encode($cat) ?>" <?= $filters->category === $cat ? 'selected' : '' ?>>
+ <?= Html::encode($cat) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="col-md-2">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Подкатегория</label>
+ <select class="form-select form-select-sm pm-filter" name="subcategory" id="pm-filter-subcategory"
+ <?= empty($subcategories) ? 'disabled' : '' ?>>
+ <option value="">Все</option>
+ <?php foreach ($subcategories as $sub): ?>
+ <option value="<?= Html::encode($sub) ?>" <?= $filters->subcategory === $sub ? 'selected' : '' ?>>
+ <?= Html::encode($sub) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="col-md-2">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Вид</label>
+ <select class="form-select form-select-sm pm-filter" name="species" id="pm-filter-species"
+ <?= empty($speciesList) ? 'disabled' : '' ?>>
+ <option value="">Все</option>
+ <?php foreach ($speciesList as $sp): ?>
+ <option value="<?= Html::encode($sp) ?>" <?= $filters->species === $sp ? 'selected' : '' ?>>
+ <?= Html::encode($sp) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="col-md-2">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Папка 1С</label>
+ <select class="form-select form-select-sm pm-filter" name="folder_1c">
+ <option value="">Все</option>
+ <?php foreach ($folders1c as $fid => $fname): ?>
+ <option value="<?= Html::encode($fid) ?>" <?= $filters->folder_1c === $fid ? 'selected' : '' ?>>
+ <?= Html::encode($fname) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="col-md-2">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Поставщик</label>
+ <select class="form-select form-select-sm pm-filter" name="supplier_id">
+ <option value="">Все</option>
+ <?php foreach ($suppliers as $sid => $sname): ?>
+ <option value="<?= (int)$sid ?>" <?= (int)$filters->supplier_id === (int)$sid ? 'selected' : '' ?>>
+ <?= Html::encode($sname) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="col-md-2">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Маркировка</label>
+ <select class="form-select form-select-sm pm-filter" name="marking_id">
+ <option value="">Все</option>
+ <?php foreach ($markings as $mid => $mlabel): ?>
+ <option value="<?= (int)$mid ?>" <?= (int)$filters->marking_id === (int)$mid ? 'selected' : '' ?>>
+ <?= Html::encode($mlabel) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ </div>
+
+ <div class="row g-2 mt-1">
+ <div class="col-md-4">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Поиск по названию</label>
+ <input type="search" class="form-control form-control-sm pm-filter" name="search"
+ value="<?= Html::encode($filters->search ?? '') ?>"
+ placeholder="Введите часть названия...">
+ </div>
+
+ <div class="col-md-3">
+ <label class="form-label fw-semibold mb-1" style="font-size:10px;">Актуальность</label>
+ <select class="form-select form-select-sm pm-filter" name="is_actual">
+ <option value="">Все</option>
+ <option value="1" <?= $filters->is_actual === true ? 'selected' : '' ?>>Только актуальные</option>
+ <option value="0" <?= $filters->is_actual === false ? 'selected' : '' ?>>Только неактуальные</option>
+ </select>
+ </div>
+
+ <div class="col-md-3 d-flex align-items-end">
+ <div class="form-check">
+ <input class="form-check-input pm-filter" type="checkbox" id="pm-filter-only-without"
+ name="only_without_supplier" value="1"
+ <?= $filters->only_without_supplier ? 'checked' : '' ?>>
+ <label class="form-check-label" for="pm-filter-only-without" style="font-size:11px;">
+ Только без поставщика
+ </label>
+ </div>
+ </div>
+
+ <div class="col-md-2 d-flex align-items-end justify-content-end">
+ <button type="button" class="btn btn-outline-secondary btn-sm" id="pm-filter-reset">
+ <i class="fa fa-times me-1"></i>Сбросить
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii\helpers\Url;
+
+/** @var yii_app\records\ProductMapping $model */
+/** @var yii_app\records\Products1cNomenclature|null $product */
+/** @var array<int,string> $suppliers */
+/** @var array<int,string> $plantations */
+/** @var array<int,string> $markings */
+/** @var int[] $selectedMarkingIds */
+?>
+
+<form id="mapping-form">
+ <input type="hidden" name="<?= Yii::$app->request->csrfParam ?>" value="<?= Yii::$app->request->csrfToken ?>">
+ <input type="hidden" name="ProductMapping[product_guid]" value="<?= Html::encode($model->product_guid) ?>">
+
+ <?php if ($product): ?>
+ <div class="mb-2 p-2" style="background:#f8f9fa;border-radius:4px;font-size:11px;">
+ <strong>Товар 1С:</strong> <?= Html::encode($product->name) ?>
+ <br><span class="text-muted">GUID: <?= Html::encode($product->id) ?></span>
+ </div>
+ <?php endif; ?>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Поставщик *</label>
+ <select class="form-select form-select-sm" name="ProductMapping[supplier_id]" required>
+ <option value="">— выберите —</option>
+ <?php foreach ($suppliers as $id => $name): ?>
+ <option value="<?= (int)$id ?>" <?= ((int)$model->supplier_id === (int)$id) ? 'selected' : '' ?>>
+ <?= Html::encode($name) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Плантация</label>
+ <select class="form-select form-select-sm" name="ProductMapping[plantation_id]">
+ <option value="">— не указана —</option>
+ <?php foreach ($plantations as $id => $label): ?>
+ <option value="<?= (int)$id ?>" <?= ((int)$model->plantation_id === (int)$id) ? 'selected' : '' ?>>
+ <?= Html::encode($label) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Название товара у поставщика *</label>
+ <input type="text" class="form-control form-control-sm" name="ProductMapping[supplier_product_name]"
+ value="<?= Html::encode($model->supplier_product_name) ?>" maxlength="500"
+ placeholder="Роза Red Naomi 50 см" required>
+ </div>
+
+ <div class="d-flex gap-2 mb-2">
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Артикул</label>
+ <input type="text" class="form-control form-control-sm" name="ProductMapping[article]"
+ value="<?= Html::encode($model->article ?? '') ?>" maxlength="100">
+ </div>
+ <div class="flex-fill">
+ <label class="form-label fw-semibold" style="font-size:11px;">Штрихкод</label>
+ <input type="text" class="form-control form-control-sm" name="ProductMapping[barcode]"
+ value="<?= Html::encode($model->barcode ?? '') ?>" maxlength="50">
+ </div>
+ <div style="width:80px;">
+ <label class="form-label fw-semibold" style="font-size:11px;">Квант *</label>
+ <input type="number" class="form-control form-control-sm" name="ProductMapping[quant]"
+ value="<?= (int)$model->quant ?>" min="1" required>
+ </div>
+ </div>
+
+ <div class="mb-2">
+ <label class="form-label fw-semibold" style="font-size:11px;">Маркировки</label>
+ <select class="form-select form-select-sm" name="ProductMapping[marking_ids][]" multiple size="5"
+ style="font-size:11px;">
+ <?php foreach ($markings as $id => $label): ?>
+ <option value="<?= (int)$id ?>" <?= in_array((int)$id, $selectedMarkingIds, true) ? 'selected' : '' ?>>
+ <?= Html::encode($label) ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ <div class="form-text" style="font-size:10px;">
+ Ctrl+клик для выбора нескольких.
+ Нет нужной маркировки?
+ <a href="<?= Url::to(['/buyer-reference/index']) ?>#tab-markings" target="_blank">Откройте справочник маркировок</a>
+ в новой вкладке.
+ </div>
+ </div>
+
+ <div class="d-flex justify-content-end gap-2 pt-2 border-top mt-3">
+ <button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
+ <button type="button" class="btn btn-primary btn-sm" id="btn-mapping-save">
+ <i class="fa fa-save me-1"></i>Сохранить
+ </button>
+ </div>
+</form>
--- /dev/null
+<?php
+
+use yii\helpers\Html;
+use yii\helpers\Url;
+use yii\widgets\LinkPager;
+
+/** @var yii\web\View $this */
+/** @var yii_app\records\Products1cNomenclature[] $products */
+/** @var array<string,yii_app\records\ProductMapping[]> $mappingsByGuid */
+/** @var yii\data\Pagination $pagination */
+/** @var int $perPage */
+/** @var int[] $perPageOptions */
+/** @var yii_app\forms\ProductMappingFilterForm $filters */
+/** @var array $analytics */
+/** @var string[] $categories */
+/** @var string[] $subcategories */
+/** @var string[] $speciesList */
+/** @var array<string,string> $folders1c */
+/** @var array<int,string> $suppliers */
+/** @var array<int,string> $markings */
+?>
+
+<style>
+ .pm-card { transition: box-shadow 0.15s; }
+ .pm-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
+ .pm-badge-danger { background:#f8d7da; color:#842029; font-size:9px; padding:1px 6px; border-radius:3px; margin-left:6px; }
+ .pm-badge-warning { background:#fff3cd; color:#664d03; font-size:9px; padding:1px 6px; border-radius:3px; margin-left:6px; }
+ .marking-code { background:#4a1d8e; color:#fff; font-family:monospace; font-size:9px; padding:1px 5px; border-radius:3px; display:inline-block; margin-right:3px; margin-bottom:2px; letter-spacing:0.3px; font-weight:600; }
+ .pm-pager .pagination { margin:0; }
+ .pm-pager .page-link { font-size:11px; padding:4px 8px; }
+ #pm-filters-card .form-select-sm, #pm-filters-card .form-control-sm { font-size:11px; }
+</style>
+
+<div class="d-flex justify-content-between align-items-center mb-2">
+ <div>
+ <strong class="fs-5" style="color:#1e3a5f;">
+ <i class="fa fa-link me-1"></i>Маппинг товаров 1С
+ </strong>
+ <br><span class="text-muted" style="font-size:12px;">
+ Товары из номенклатуры 1С и привязка к поставщикам
+ </span>
+ </div>
+ <div class="d-flex align-items-center gap-2">
+ <button type="button" class="btn btn-outline-success btn-sm" id="pm-export-btn" title="Экспорт в .xlsx с учётом фильтров">
+ <i class="fa fa-file-excel-o me-1"></i>Экспорт
+ </button>
+ <label class="text-muted" style="font-size:11px;">На странице:</label>
+ <select class="form-select form-select-sm" id="pm-per-page" style="width:80px;">
+ <?php foreach ($perPageOptions as $opt): ?>
+ <option value="<?= $opt ?>" <?= $opt === $perPage ? 'selected' : '' ?>><?= $opt ?></option>
+ <?php endforeach; ?>
+ </select>
+ <span class="text-muted" style="font-size:11px;">
+ Показано: <strong><?= $pagination->totalCount ?></strong>
+ </span>
+ </div>
+</div>
+
+<?= $this->render('_analytics', ['analytics' => $analytics]) ?>
+
+<?= $this->render('_filters', [
+ 'filters' => $filters,
+ 'categories' => $categories,
+ 'subcategories' => $subcategories,
+ 'speciesList' => $speciesList,
+ 'folders1c' => $folders1c,
+ 'suppliers' => $suppliers,
+ 'markings' => $markings,
+]) ?>
+
+<div id="pm-cards-container">
+ <?php if (empty($products)): ?>
+ <div class="text-center text-muted p-5">
+ <i class="fa fa-inbox me-1"></i>Нет товаров, соответствующих фильтрам
+ </div>
+ <?php endif; ?>
+
+ <?php foreach ($products as $product): ?>
+ <?= $this->render('_card', [
+ 'product' => $product,
+ 'mappings' => $mappingsByGuid[$product->id] ?? [],
+ ]) ?>
+ <?php endforeach; ?>
+</div>
+
+<?php if ($pagination->pageCount > 1): ?>
+ <div class="pm-pager d-flex justify-content-center mt-3">
+ <?= LinkPager::widget([
+ 'pagination' => $pagination,
+ 'options' => ['class' => 'pagination pagination-sm'],
+ ]) ?>
+ </div>
+<?php endif; ?>
+
+<!-- Модальное окно -->
+<div class="modal fade" id="pm-modal" tabindex="-1">
+ <div class="modal-dialog modal-lg">
+ <div class="modal-content">
+ <div class="modal-header" style="background:#1e3a5f;color:#fff;">
+ <h5 class="modal-title">
+ <i class="fa fa-link me-1"></i>
+ <span id="pm-modal-title">Маппинг товара</span>
+ </h5>
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
+ </div>
+ <div class="modal-body" id="pm-modal-body"></div>
+ </div>
+ </div>
+</div>
+
+<?php
+$createFormUrl = Url::to(['/product-mapping/create-form']);
+$updateFormUrl = Url::to(['/product-mapping/update-form']);
+$createUrl = Url::to(['/product-mapping/create']);
+$updateUrl = Url::to(['/product-mapping/update']);
+$deleteUrl = Url::to(['/product-mapping/delete']);
+$indexUrl = Url::to(['/product-mapping/index']);
+$analyticsUrl = Url::to(['/product-mapping/analytics']);
+$cascadeUrl = Url::to(['/product-mapping/cascade-filters']);
+$exportUrl = Url::to(['/product-mapping/export']);
+
+$js = <<<JS
+(function() {
+ var pmModal = new bootstrap.Modal(document.getElementById('pm-modal'));
+ var editingId = null;
+ var searchTimer = null;
+ var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
+
+ function collectFilters() {
+ var params = {};
+ $('.pm-filter').each(function() {
+ var \$el = $(this);
+ var name = \$el.attr('name');
+ if (!name) return;
+ if (\$el.is(':checkbox')) {
+ if (\$el.is(':checked')) {
+ params[name] = '1';
+ }
+ } else {
+ var v = \$el.val();
+ if (v !== '' && v !== null) {
+ params[name] = v;
+ }
+ }
+ });
+ params['per-page'] = $('#pm-per-page').val();
+ return params;
+ }
+
+ function reloadList(extraParams) {
+ var params = collectFilters();
+ if (extraParams) {
+ \$.extend(params, extraParams);
+ }
+ var \$tab = $('#tab-mappings');
+ \$tab.html('<div class="text-muted p-4 text-center"><i class="fa fa-spinner fa-spin me-2"></i>Загрузка...</div>');
+ \$.get('{$indexUrl}', params, function(html) {
+ \$tab.html(html);
+ });
+ }
+
+ function reloadAnalytics() {
+ var params = collectFilters();
+ delete params['per-page'];
+ \$.get('{$analyticsUrl}', params, function(data) {
+ \$.each(data, function(field, value) {
+ $('[data-analytics-field="' + field + '"]').text(value);
+ });
+ }, 'json');
+ }
+
+ // ========= ФИЛЬТРЫ =========
+
+ // Смена фильтров — reload списка
+ $(document).on('change', '.pm-filter', function() {
+ var name = $(this).attr('name');
+
+ // Каскадное обновление категорий
+ if (name === 'category') {
+ var category = $(this).val();
+ $('#pm-filter-subcategory').html('<option value="">Все</option>').prop('disabled', !category);
+ $('#pm-filter-species').html('<option value="">Все</option>').prop('disabled', true);
+ if (category) {
+ \$.get('{$cascadeUrl}', {category: category}, function(data) {
+ var html = '<option value="">Все</option>';
+ \$.each(data.subcategories, function(_, sub) {
+ html += '<option value="' + sub + '">' + sub + '</option>';
+ });
+ $('#pm-filter-subcategory').html(html).prop('disabled', false);
+ }, 'json');
+ }
+ } else if (name === 'subcategory') {
+ var cat = $('#pm-filter-category').val();
+ var sub = $(this).val();
+ $('#pm-filter-species').html('<option value="">Все</option>').prop('disabled', !sub);
+ if (cat && sub) {
+ \$.get('{$cascadeUrl}', {category: cat, subcategory: sub}, function(data) {
+ var html = '<option value="">Все</option>';
+ \$.each(data.species, function(_, sp) {
+ html += '<option value="' + sp + '">' + sp + '</option>';
+ });
+ $('#pm-filter-species').html(html).prop('disabled', false);
+ }, 'json');
+ }
+ }
+
+ // Для поиска по названию — debounce
+ if (name === 'search') return;
+
+ reloadList({page: 1});
+ });
+
+ // Поиск — с debounce 400мс
+ $(document).on('input', 'input[name="search"].pm-filter', function() {
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(function() {
+ reloadList({page: 1});
+ }, 400);
+ });
+
+ // Сброс фильтров
+ $(document).on('click', '#pm-filter-reset', function() {
+ $('.pm-filter').each(function() {
+ var \$el = $(this);
+ if (\$el.is(':checkbox')) {
+ \$el.prop('checked', false);
+ } else if (\$el.is('select')) {
+ \$el.val('');
+ } else {
+ \$el.val('');
+ }
+ });
+ reloadList({page: 1});
+ });
+
+ // Смена per-page
+ $('#pm-per-page').on('change', function() {
+ reloadList({page: 1});
+ });
+
+ // Экспорт в .xlsx — редирект с текущими фильтрами
+ $(document).on('click', '#pm-export-btn', function() {
+ var params = collectFilters();
+ delete params['per-page'];
+ var qs = $.param(params);
+ window.location.href = '{$exportUrl}' + (qs ? '?' + qs : '');
+ });
+
+ // Пагинация — перехват ссылок
+ $(document).on('click', '.pm-pager a', function(e) {
+ e.preventDefault();
+ var href = $(this).attr('href');
+ var pageMatch = href.match(/[?&]page=(\d+)/);
+ var page = pageMatch ? pageMatch[1] : 1;
+ reloadList({page: page});
+ });
+
+ // ========= МОДАЛКА МАППИНГА =========
+
+ $(document).on('click', '.btn-pm-add', function() {
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingId = null;
+ var guid = \$btn.data('product-guid');
+ $('#pm-modal-title').text('Добавить поставщика');
+ $('#pm-modal-body').html(loaderHtml);
+ pmModal.show();
+ \$.get('{$createFormUrl}', {product_guid: guid}, function(html) {
+ $('#pm-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '.btn-pm-edit', function(e) {
+ e.preventDefault();
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingId = \$btn.data('id');
+ $('#pm-modal-title').text('Редактировать маппинг');
+ $('#pm-modal-body').html(loaderHtml);
+ pmModal.show();
+ \$.get('{$updateFormUrl}', {id: editingId}, function(html) {
+ $('#pm-modal-body').html(html);
+ }).always(function() {
+ \$btn.prop('disabled', false);
+ });
+ });
+
+ $(document).on('click', '#btn-mapping-save', function() {
+ var \$form = $('#mapping-form');
+ var url = editingId ? '{$updateUrl}?id=' + editingId : '{$createUrl}';
+
+ \$form.find('.is-invalid').removeClass('is-invalid');
+ \$form.find('.invalid-feedback').remove();
+
+ \$.ajax({
+ url: url,
+ type: 'POST',
+ data: \$form.serialize(),
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ pmModal.hide();
+ reloadList();
+ } else if (resp.errors) {
+ \$.each(resp.errors, function(field, messages) {
+ var \$input = \$form.find('[name="ProductMapping[' + field + ']"]');
+ if (!\$input.length) {
+ \$input = \$form.find('[name="ProductMapping[' + field + '][]"]');
+ }
+ \$input.addClass('is-invalid');
+ \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ $(document).on('click', '.btn-pm-delete', function(e) {
+ e.preventDefault();
+ var id = $(this).data('id');
+ var name = $(this).data('name');
+
+ if (!confirm('Удалить маппинг на поставщика "' + name + '"?')) {
+ return;
+ }
+
+ \$.ajax({
+ url: '{$deleteUrl}?id=' + id,
+ type: 'POST',
+ data: {_csrf: yii.getCsrfToken()},
+ dataType: 'json',
+ success: function(resp) {
+ if (resp.success) {
+ reloadList();
+ } else {
+ alert(resp.message || 'Ошибка удаления');
+ }
+ },
+ error: function() { alert('Ошибка сервера'); }
+ });
+ });
+
+ // Очистка ошибок при вводе
+ $(document).on('input change', '#mapping-form input, #mapping-form select', function() {
+ $(this).removeClass('is-invalid');
+ $(this).next('.invalid-feedback').remove();
+ });
+})();
+JS;
+
+$this->registerJs($js);
+?>
]) ?>
</div>
<div style="width:80px;">
- <label class="form-label fw-semibold" style="font-size:11px;">Lead time *</label>
+ <label class="form-label fw-semibold" style="font-size:11px;">Срок поставки *</label>
<input type="number" class="form-control form-control-sm" name="Supplier[lead_time_days]"
value="<?= (int)$model->lead_time_days ?>" min="0" required>
</div>
<div class="d-flex justify-content-end gap-2 pt-2 border-top mt-3">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary btn-sm" id="btn-supplier-save">
- <i class="fas fa-save me-1"></i>Сохранить
+ <i class="fa fa-save me-1"></i>Сохранить
</button>
</div>
</form>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<strong class="fs-5" style="color:#1e3a5f;">
- <i class="fas fa-building me-1"></i>Справочник поставщиков
+ <i class="fa fa-building me-1"></i>Справочник поставщиков
</strong>
<br><span class="text-muted" style="font-size:12px;">Локальные и международные</span>
</div>
<button class="btn btn-primary btn-sm" id="btn-supplier-create">
- <i class="fas fa-plus me-1"></i>Добавить
+ <i class="fa fa-plus me-1"></i>Добавить
</button>
</div>
],
[
'attribute' => 'lead_time_days',
- 'label' => 'Lead time',
+ 'label' => 'Срок поставки',
'contentOptions' => ['style' => 'text-align:center;'],
'headerOptions' => ['style' => 'text-align:center;'],
'value' => function ($model) {
'buttons' => [
'update' => function ($url, $model) {
return Html::a(
- '<i class="fas fa-pen text-secondary"></i>',
+ '<i class="fa fa-pencil text-secondary"></i>',
'#',
[
'class' => 'btn-supplier-edit',
},
'delete' => function ($url, $model) {
if (!$model->is_active) {
- return ' <i class="fas fa-trash text-secondary" style="opacity:0.3;cursor:default;"></i>';
+ return ' <i class="fa fa-trash text-secondary" style="opacity:0.3;cursor:default;"></i>';
}
return ' ' . Html::a(
- '<i class="fas fa-trash text-danger"></i>',
+ '<i class="fa fa-trash text-danger"></i>',
'#',
[
'class' => 'btn-supplier-delete',
<div class="modal-content">
<div class="modal-header" style="background:#1e3a5f;color:#fff;">
<h5 class="modal-title">
- <i class="fas fa-building me-1"></i>
+ <i class="fa fa-building me-1"></i>
<span id="supplier-modal-title">Добавить поставщика</span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
var supplierModal = new bootstrap.Modal(document.getElementById('supplier-modal'));
var editingId = null;
+ var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
+
// Создание
$('#btn-supplier-create').on('click', function() {
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
editingId = null;
$('#supplier-modal-title').text('Добавить поставщика');
+ $('#supplier-modal-body').html(loaderHtml);
+ supplierModal.show();
$.get('{$createFormUrl}', function(html) {
$('#supplier-modal-body').html(html);
- supplierModal.show();
+ }).always(function() {
+ \$btn.prop('disabled', false);
});
});
// Редактирование
$(document).on('click', '.btn-supplier-edit', function(e) {
e.preventDefault();
- editingId = $(this).data('id');
+ var \$btn = $(this);
+ if (\$btn.prop('disabled')) return;
+ \$btn.prop('disabled', true);
+ editingId = \$btn.data('id');
$('#supplier-modal-title').text('Редактировать поставщика');
+ $('#supplier-modal-body').html(loaderHtml);
+ supplierModal.show();
$.get('{$updateFormUrl}', {id: editingId}, function(html) {
$('#supplier-modal-body').html(html);
- supplierModal.show();
+ }).always(function() {
+ \$btn.prop('disabled', false);
});
});
--- /dev/null
+.budget-module .progress {
+ background-color: #e9ecef;
+ border-radius: 4px;
+}
+
+.budget-module .progress-bar {
+ transition: width 0.3s ease-in-out;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 30px;
+ color: #fff;
+}
+
+.budget-module .card {
+ border: 1px solid #dee2e6;
+ border-radius: 8px;
+}
+
+.budget-module .card-title {
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.budget-module .badge {
+ font-size: 12px;
+ padding: 4px 8px;
+}
+
+#budget-table th {
+ background-color: #f8f9fa;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+#budget-table td {
+ font-size: 13px;
+ vertical-align: middle;
+}
+
+#approvals-table th {
+ background-color: #fff3cd;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+#approvals-table td {
+ font-size: 13px;
+ vertical-align: middle;
+}
--- /dev/null
+/**
+ * Budget module — JS logic.
+ * Прогресс-бары, AJAX CRUD, approval workflow.
+ */
+
+// CSRF token для AJAX POST-запросов
+$.ajaxSetup({
+ headers: {
+ 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
+ }
+});
+
+function loadProgress() {
+ var category = document.getElementById('filter-category').value;
+ var periodStart = document.getElementById('filter-period').value;
+
+ var url = '/budget/get-progress?';
+ if (category) url += 'category=' + encodeURIComponent(category) + '&';
+ if (periodStart) url += 'period_start=' + encodeURIComponent(periodStart);
+
+ $.ajax({
+ url: url,
+ type: 'GET',
+ dataType: 'json',
+ success: function (response) {
+ if (response.success) {
+ renderProgressBars(response.data);
+ renderBudgetTable(response.data);
+ }
+ },
+ error: function () {
+ console.error('Ошибка загрузки прогресса бюджета');
+ }
+ });
+}
+
+function renderProgressBars(budgets) {
+ var container = document.getElementById('progress-container');
+ container.innerHTML = '';
+
+ budgets.forEach(function (b) {
+ var pct = b.usage_pct;
+ var color = '#28a745';
+ if (b.is_exceeded) color = '#dc3545';
+ else if (b.is_alert) color = '#ffc107';
+
+ var html = '<div class="col-md-6 mb-3">' +
+ '<div class="card"><div class="card-body">' +
+ '<h6 class="card-title">' + escapeHtml(b.category) + ' ' + b.period_start + ' — ' + b.period_end + '</h6>' +
+ '<div class="progress" style="height:30px;">' +
+ '<div class="progress-bar" style="width:' + Math.min(pct, 100) + '%;background-color:' + color + ';">' +
+ pct + '%</div></div>' +
+ '<div class="mt-1 text-muted">' +
+ formatNumber(b.used_amount) + ' ₽ / ' + formatNumber(b.limit_amount) + ' ₽ | Остаток: ' + formatNumber(b.remaining) + ' ₽</div>' +
+ '</div></div></div>';
+
+ container.innerHTML += html;
+ });
+}
+
+function renderBudgetTable(budgets) {
+ var tbody = document.querySelector('#budget-table tbody');
+ tbody.innerHTML = '';
+
+ budgets.forEach(function (b) {
+ var statusBadge;
+ if (b.is_exceeded) {
+ statusBadge = '<span class="badge bg-danger">Превышен</span>';
+ } else if (b.is_alert) {
+ statusBadge = '<span class="badge bg-warning text-dark">Предупреждение</span>';
+ } else {
+ statusBadge = '<span class="badge bg-success">Норма</span>';
+ }
+
+ var row = '<tr>' +
+ '<td>' + b.period_start + ' — ' + b.period_end + '</td>' +
+ '<td>' + escapeHtml(b.category) + '</td>' +
+ '<td>' + formatNumber(b.limit_amount) + '</td>' +
+ '<td>' + formatNumber(b.used_amount) + '</td>' +
+ '<td>' + b.usage_pct + '%</td>' +
+ '<td>' + b.alert_threshold_pct + '%</td>' +
+ '<td>' + statusBadge + '</td>' +
+ '</tr>';
+
+ tbody.innerHTML += row;
+ });
+}
+
+function saveBudget() {
+ var data = {
+ category: document.getElementById('form-category').value,
+ period_start: document.getElementById('form-period-start').value,
+ limit_amount: document.getElementById('form-limit').value,
+ alert_threshold_pct: document.getElementById('form-threshold').value
+ };
+
+ if (!data.category || !data.period_start || !data.limit_amount) {
+ alert('Заполните все обязательные поля');
+ return;
+ }
+
+ // Проверка: понедельник
+ var d = new Date(data.period_start);
+ if (d.getDay() !== 1) {
+ alert('Период должен начинаться с понедельника');
+ return;
+ }
+
+ $.ajax({
+ url: '/budget/save',
+ type: 'POST',
+ data: data,
+ dataType: 'json',
+ success: function (response) {
+ if (response.success) {
+ var modal = bootstrap.Modal.getInstance(document.getElementById('budgetFormModal'));
+ if (modal) modal.hide();
+ loadProgress();
+ } else {
+ alert(response.message || 'Ошибка сохранения');
+ }
+ },
+ error: function () {
+ alert('Ошибка сервера');
+ }
+ });
+}
+
+function requestApproval() {
+ var budgetId = document.getElementById('approval-budget-id').value;
+ var amount = document.getElementById('approval-amount').value;
+ var reason = document.getElementById('approval-reason').value;
+
+ if (!amount || !reason) {
+ alert('Заполните все поля');
+ return;
+ }
+
+ $.ajax({
+ url: '/budget/request-approval',
+ type: 'POST',
+ data: {budget_id: budgetId, amount: amount, reason: reason},
+ dataType: 'json',
+ success: function (response) {
+ if (response.success) {
+ var modal = bootstrap.Modal.getInstance(document.getElementById('approvalRequestModal'));
+ if (modal) modal.hide();
+ location.reload();
+ } else {
+ alert(response.message || 'Ошибка');
+ }
+ },
+ error: function () {
+ alert('Ошибка сервера');
+ }
+ });
+}
+
+function resolveApproval(approvalId, status) {
+ var action = status === 'approved' ? 'одобрить' : 'отклонить';
+ if (!confirm('Вы уверены, что хотите ' + action + ' запрос #' + approvalId + '?')) {
+ return;
+ }
+
+ $.ajax({
+ url: '/budget/resolve-approval',
+ type: 'POST',
+ data: {approval_id: approvalId, status: status},
+ dataType: 'json',
+ success: function (response) {
+ if (response.success) {
+ location.reload();
+ } else {
+ alert(response.message || 'Ошибка');
+ }
+ },
+ error: function () {
+ alert('Ошибка сервера');
+ }
+ });
+}
+
+function openApprovalModal(budgetId) {
+ document.getElementById('approval-budget-id').value = budgetId;
+ document.getElementById('approval-amount').value = '';
+ document.getElementById('approval-reason').value = '';
+ var modal = new bootstrap.Modal(document.getElementById('approvalRequestModal'));
+ modal.show();
+}
+
+// Helpers
+function formatNumber(n) {
+ return new Intl.NumberFormat('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 0}).format(n);
+}
+
+function escapeHtml(text) {
+ var div = document.createElement('div');
+ div.appendChild(document.createTextNode(text));
+ return div.innerHTML;
+}
+
+// Auto-refresh every 60 seconds
+setInterval(loadProgress, 60000);