]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Каталог поставщика
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 14 Apr 2026 07:32:34 +0000 (10:32 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 14 Apr 2026 07:32:34 +0000 (10:32 +0300)
46 files changed:
erp24/commands/ProductMappingController.php [new file with mode: 0644]
erp24/controllers/MarkingController.php [new file with mode: 0644]
erp24/controllers/ProducerController.php [new file with mode: 0644]
erp24/controllers/ProductMappingController.php [new file with mode: 0644]
erp24/controllers/SupplierController.php
erp24/forms/ProductMappingFilterForm.php [new file with mode: 0644]
erp24/migrations/m260408_100000_create_suppliers_table.php
erp24/migrations/m260408_100100_create_producers_table.php [new file with mode: 0644]
erp24/migrations/m260408_100200_create_plantations_table.php [new file with mode: 0644]
erp24/migrations/m260408_100300_create_markings_table.php [new file with mode: 0644]
erp24/migrations/m260408_100400_create_product_mappings_table.php [new file with mode: 0644]
erp24/migrations/m260408_100500_create_mapping_markings_table.php [new file with mode: 0644]
erp24/migrations/m260409_120000_add_erp300_rbac_permissions.php [new file with mode: 0644]
erp24/records/MappingMarking.php [new file with mode: 0644]
erp24/records/MarkingSearch.php [new file with mode: 0644]
erp24/records/Plantation.php [new file with mode: 0644]
erp24/records/Producer.php [new file with mode: 0644]
erp24/records/ProducerSearch.php [new file with mode: 0644]
erp24/records/ProductMapping.php [new file with mode: 0644]
erp24/records/Supplier.php
erp24/services/ProductMappingService.php [new file with mode: 0644]
erp24/tests/unit/commands/ProductMappingCommandTest.php [new file with mode: 0644]
erp24/tests/unit/controllers/Erp300ControllersTest.php [new file with mode: 0644]
erp24/tests/unit/forms/ProductMappingFilterFormTest.php [new file with mode: 0644]
erp24/tests/unit/migrations/Erp300RbacMigrationTest.php [new file with mode: 0644]
erp24/tests/unit/records/MarkingTest.php [new file with mode: 0644]
erp24/tests/unit/records/PlantationTest.php [new file with mode: 0644]
erp24/tests/unit/records/ProducerTest.php [new file with mode: 0644]
erp24/tests/unit/records/ProductMappingTest.php [new file with mode: 0644]
erp24/tests/unit/records/SupplierTest.php [new file with mode: 0644]
erp24/tests/unit/services/ProductMappingServiceTest.php [new file with mode: 0644]
erp24/views/buyer-reference/index.php
erp24/views/marking/_form.php [new file with mode: 0644]
erp24/views/marking/index.php [new file with mode: 0644]
erp24/views/producer/_plantation_form.php [new file with mode: 0644]
erp24/views/producer/_producer_form.php [new file with mode: 0644]
erp24/views/producer/index.php [new file with mode: 0644]
erp24/views/product-mapping/_analytics.php [new file with mode: 0644]
erp24/views/product-mapping/_card.php [new file with mode: 0644]
erp24/views/product-mapping/_filters.php [new file with mode: 0644]
erp24/views/product-mapping/_form.php [new file with mode: 0644]
erp24/views/product-mapping/index.php [new file with mode: 0644]
erp24/views/supplier/_form.php
erp24/views/supplier/index.php
erp24/web/css/budget/style.css [new file with mode: 0644]
erp24/web/js/budget/index.js [new file with mode: 0644]

diff --git a/erp24/commands/ProductMappingController.php b/erp24/commands/ProductMappingController.php
new file mode 100644 (file)
index 0000000..2407598
--- /dev/null
@@ -0,0 +1,107 @@
+<?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;
+        }
+    }
+}
diff --git a/erp24/controllers/MarkingController.php b/erp24/controllers/MarkingController.php
new file mode 100644 (file)
index 0000000..d6d03e9
--- /dev/null
@@ -0,0 +1,211 @@
+<?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();
+    }
+}
diff --git a/erp24/controllers/ProducerController.php b/erp24/controllers/ProducerController.php
new file mode 100644 (file)
index 0000000..b0a4238
--- /dev/null
@@ -0,0 +1,282 @@
+<?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();
+    }
+}
diff --git a/erp24/controllers/ProductMappingController.php b/erp24/controllers/ProductMappingController.php
new file mode 100644 (file)
index 0000000..3f83a22
--- /dev/null
@@ -0,0 +1,335 @@
+<?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;
+    }
+}
index 2743a74554fdf53c87410b328907532352e6130c..3c9a529f9e8915d4bed60cfd0a0352bcee210d2f 100644 (file)
@@ -45,7 +45,7 @@ class SupplierController extends BaseController
         $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,
             ]);
diff --git a/erp24/forms/ProductMappingFilterForm.php b/erp24/forms/ProductMappingFilterForm.php
new file mode 100644 (file)
index 0000000..a94e5b7
--- /dev/null
@@ -0,0 +1,150 @@
+<?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());
+    }
+}
index c1488b0cd714f6f5711918867d174dc41df9b7cb..ac63317ebee3b16a9c75a3e307f6594911c68192 100644 (file)
@@ -10,6 +10,11 @@ class m260408_100000_create_suppliers_table extends Migration
 {
     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('Название поставщика'),
diff --git a/erp24/migrations/m260408_100100_create_producers_table.php b/erp24/migrations/m260408_100100_create_producers_table.php
new file mode 100644 (file)
index 0000000..9907780
--- /dev/null
@@ -0,0 +1,33 @@
+<?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}}');
+    }
+}
diff --git a/erp24/migrations/m260408_100200_create_plantations_table.php b/erp24/migrations/m260408_100200_create_plantations_table.php
new file mode 100644 (file)
index 0000000..358f3e1
--- /dev/null
@@ -0,0 +1,58 @@
+<?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}}');
+    }
+}
diff --git a/erp24/migrations/m260408_100300_create_markings_table.php b/erp24/migrations/m260408_100300_create_markings_table.php
new file mode 100644 (file)
index 0000000..a5ada6c
--- /dev/null
@@ -0,0 +1,45 @@
+<?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}}');
+    }
+}
diff --git a/erp24/migrations/m260408_100400_create_product_mappings_table.php b/erp24/migrations/m260408_100400_create_product_mappings_table.php
new file mode 100644 (file)
index 0000000..4d4feee
--- /dev/null
@@ -0,0 +1,56 @@
+<?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}}');
+    }
+}
diff --git a/erp24/migrations/m260408_100500_create_mapping_markings_table.php b/erp24/migrations/m260408_100500_create_mapping_markings_table.php
new file mode 100644 (file)
index 0000000..391c3a5
--- /dev/null
@@ -0,0 +1,36 @@
+<?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}}');
+    }
+}
diff --git a/erp24/migrations/m260409_120000_add_erp300_rbac_permissions.php b/erp24/migrations/m260409_120000_add_erp300_rbac_permissions.php
new file mode 100644 (file)
index 0000000..f58d86b
--- /dev/null
@@ -0,0 +1,147 @@
+<?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";
+    }
+}
diff --git a/erp24/records/MappingMarking.php b/erp24/records/MappingMarking.php
new file mode 100644 (file)
index 0000000..4b54bce
--- /dev/null
@@ -0,0 +1,36 @@
+<?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'],
+        ];
+    }
+}
diff --git a/erp24/records/MarkingSearch.php b/erp24/records/MarkingSearch.php
new file mode 100644 (file)
index 0000000..951bae6
--- /dev/null
@@ -0,0 +1,60 @@
+<?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;
+    }
+}
diff --git a/erp24/records/Plantation.php b/erp24/records/Plantation.php
new file mode 100644 (file)
index 0000000..1a4a618
--- /dev/null
@@ -0,0 +1,166 @@
+<?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);
+    }
+}
diff --git a/erp24/records/Producer.php b/erp24/records/Producer.php
new file mode 100644 (file)
index 0000000..3032838
--- /dev/null
@@ -0,0 +1,159 @@
+<?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;
+        }
+    }
+}
diff --git a/erp24/records/ProducerSearch.php b/erp24/records/ProducerSearch.php
new file mode 100644 (file)
index 0000000..4d278f6
--- /dev/null
@@ -0,0 +1,54 @@
+<?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;
+    }
+}
diff --git a/erp24/records/ProductMapping.php b/erp24/records/ProductMapping.php
new file mode 100644 (file)
index 0000000..1f7fc44
--- /dev/null
@@ -0,0 +1,251 @@
+<?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()
+        );
+    }
+}
index e58ca5596bd9dfe8b18086076dac50dc89d9c65a..69b447b8990d8fce26bffd134996c4b30ece31bb 100644 (file)
@@ -67,7 +67,7 @@ class Supplier extends ActiveRecord
             ['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],
@@ -81,7 +81,7 @@ class Supplier extends ActiveRecord
             'name' => 'Название',
             'type' => 'Тип',
             'currency' => 'Валюта',
-            'lead_time_days' => 'Lead time (дн)',
+            'lead_time_days' => 'Срок поставки (дн)',
             'is_active' => 'Статус',
             'created_by' => 'Создал',
             'updated_by' => 'Обновил',
diff --git a/erp24/services/ProductMappingService.php b/erp24/services/ProductMappingService.php
new file mode 100644 (file)
index 0000000..3ab0a7f
--- /dev/null
@@ -0,0 +1,496 @@
+<?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;
+    }
+}
diff --git a/erp24/tests/unit/commands/ProductMappingCommandTest.php b/erp24/tests/unit/commands/ProductMappingCommandTest.php
new file mode 100644 (file)
index 0000000..194b1cb
--- /dev/null
@@ -0,0 +1,63 @@
+<?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));
+    }
+}
diff --git a/erp24/tests/unit/controllers/Erp300ControllersTest.php b/erp24/tests/unit/controllers/Erp300ControllersTest.php
new file mode 100644 (file)
index 0000000..6d761e4
--- /dev/null
@@ -0,0 +1,116 @@
+<?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'
+        );
+    }
+}
diff --git a/erp24/tests/unit/forms/ProductMappingFilterFormTest.php b/erp24/tests/unit/forms/ProductMappingFilterFormTest.php
new file mode 100644 (file)
index 0000000..d65712e
--- /dev/null
@@ -0,0 +1,141 @@
+<?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);
+    }
+}
diff --git a/erp24/tests/unit/migrations/Erp300RbacMigrationTest.php b/erp24/tests/unit/migrations/Erp300RbacMigrationTest.php
new file mode 100644 (file)
index 0000000..312409a
--- /dev/null
@@ -0,0 +1,144 @@
+<?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'
+        );
+    }
+}
diff --git a/erp24/tests/unit/records/MarkingTest.php b/erp24/tests/unit/records/MarkingTest.php
new file mode 100644 (file)
index 0000000..c06cf0b
--- /dev/null
@@ -0,0 +1,170 @@
+<?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'));
+    }
+}
diff --git a/erp24/tests/unit/records/PlantationTest.php b/erp24/tests/unit/records/PlantationTest.php
new file mode 100644 (file)
index 0000000..c8c19c9
--- /dev/null
@@ -0,0 +1,97 @@
+<?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'));
+    }
+}
diff --git a/erp24/tests/unit/records/ProducerTest.php b/erp24/tests/unit/records/ProducerTest.php
new file mode 100644 (file)
index 0000000..7fc1eb7
--- /dev/null
@@ -0,0 +1,66 @@
+<?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']);
+    }
+}
diff --git a/erp24/tests/unit/records/ProductMappingTest.php b/erp24/tests/unit/records/ProductMappingTest.php
new file mode 100644 (file)
index 0000000..af25ee0
--- /dev/null
@@ -0,0 +1,127 @@
+<?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=[]');
+    }
+}
diff --git a/erp24/tests/unit/records/SupplierTest.php b/erp24/tests/unit/records/SupplierTest.php
new file mode 100644 (file)
index 0000000..64d7062
--- /dev/null
@@ -0,0 +1,105 @@
+<?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'));
+    }
+}
diff --git a/erp24/tests/unit/services/ProductMappingServiceTest.php b/erp24/tests/unit/services/ProductMappingServiceTest.php
new file mode 100644 (file)
index 0000000..8fcf6ac
--- /dev/null
@@ -0,0 +1,82 @@
+<?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());
+    }
+}
index 330f299e927338fc0f39e927f4159fea3dbf3e93..b5cd03abfc65579b25c5c9d42029be1bc7d1e0ab 100644 (file)
@@ -8,36 +8,56 @@ use yii\web\View;
 $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>
@@ -45,22 +65,22 @@ $this->title = 'Справочник закупщика';
     <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>
@@ -70,24 +90,78 @@ $this->title = 'Справочник закупщика';
 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;
diff --git a/erp24/views/marking/_form.php b/erp24/views/marking/_form.php
new file mode 100644 (file)
index 0000000..cce834e
--- /dev/null
@@ -0,0 +1,137 @@
+<?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>
diff --git a/erp24/views/marking/index.php b/erp24/views/marking/index.php
new file mode 100644 (file)
index 0000000..103f376
--- /dev/null
@@ -0,0 +1,263 @@
+<?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);
+?>
diff --git a/erp24/views/producer/_plantation_form.php b/erp24/views/producer/_plantation_form.php
new file mode 100644 (file)
index 0000000..b05fb2d
--- /dev/null
@@ -0,0 +1,50 @@
+<?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>
diff --git a/erp24/views/producer/_producer_form.php b/erp24/views/producer/_producer_form.php
new file mode 100644 (file)
index 0000000..cccaf6a
--- /dev/null
@@ -0,0 +1,24 @@
+<?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>
diff --git a/erp24/views/producer/index.php b/erp24/views/producer/index.php
new file mode 100644 (file)
index 0000000..78bacc2
--- /dev/null
@@ -0,0 +1,354 @@
+<?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);
+?>
diff --git a/erp24/views/product-mapping/_analytics.php b/erp24/views/product-mapping/_analytics.php
new file mode 100644 (file)
index 0000000..1f399f0
--- /dev/null
@@ -0,0 +1,56 @@
+<?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>
diff --git a/erp24/views/product-mapping/_card.php b/erp24/views/product-mapping/_card.php
new file mode 100644 (file)
index 0000000..7198b02
--- /dev/null
@@ -0,0 +1,102 @@
+<?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>
diff --git a/erp24/views/product-mapping/_filters.php b/erp24/views/product-mapping/_filters.php
new file mode 100644 (file)
index 0000000..818691f
--- /dev/null
@@ -0,0 +1,127 @@
+<?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>
diff --git a/erp24/views/product-mapping/_form.php b/erp24/views/product-mapping/_form.php
new file mode 100644 (file)
index 0000000..515f145
--- /dev/null
@@ -0,0 +1,98 @@
+<?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>
diff --git a/erp24/views/product-mapping/index.php b/erp24/views/product-mapping/index.php
new file mode 100644 (file)
index 0000000..f346b78
--- /dev/null
@@ -0,0 +1,357 @@
+<?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);
+?>
index d4a95b5d63cc81074c4ee76572a277b81f2032a2..9a3cf3c77ce04e8b0099da91d3d028bbc0b324ed 100644 (file)
@@ -32,7 +32,7 @@ use yii_app\records\Supplier;
             ]) ?>
         </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>
@@ -41,7 +41,7 @@ use yii_app\records\Supplier;
     <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>
index 9f7fb2d1f81801dfe5a13d595c00560d5c504599..f3a8efdf21c5c192f8e2797b72ea4564fb3974b9 100644 (file)
@@ -21,12 +21,12 @@ use yii_app\records\Supplier;
 <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>
 
@@ -59,7 +59,7 @@ use yii_app\records\Supplier;
         ],
         [
             'attribute' => 'lead_time_days',
-            'label' => 'Lead time',
+            'label' => 'Срок поставки',
             'contentOptions' => ['style' => 'text-align:center;'],
             'headerOptions' => ['style' => 'text-align:center;'],
             'value' => function ($model) {
@@ -90,7 +90,7 @@ use yii_app\records\Supplier;
             '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',
@@ -101,10 +101,10 @@ use yii_app\records\Supplier;
                 },
                 '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',
@@ -127,7 +127,7 @@ use yii_app\records\Supplier;
         <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>
@@ -149,24 +149,38 @@ $js = <<<JS
     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);
         });
     });
 
diff --git a/erp24/web/css/budget/style.css b/erp24/web/css/budget/style.css
new file mode 100644 (file)
index 0000000..3ca3bb6
--- /dev/null
@@ -0,0 +1,49 @@
+.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;
+}
diff --git a/erp24/web/js/budget/index.js b/erp24/web/js/budget/index.js
new file mode 100644 (file)
index 0000000..9166e33
--- /dev/null
@@ -0,0 +1,203 @@
+/**
+ * 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) + ' &nbsp; ' + 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) + ' &#8381; / ' + formatNumber(b.limit_amount) + ' &#8381; | Остаток: ' + formatNumber(b.remaining) + ' &#8381;</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);