From: Vladimir Fomichev Date: Tue, 14 Apr 2026 07:32:34 +0000 (+0300) Subject: Каталог поставщика X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=ce9357d42c8c290ae3d2f5bad6a8417c731ea8f3;p=erp24_rep%2Fyii-erp24%2F.git Каталог поставщика --- diff --git a/erp24/commands/ProductMappingController.php b/erp24/commands/ProductMappingController.php new file mode 100644 index 00000000..2407598b --- /dev/null +++ b/erp24/commands/ProductMappingController.php @@ -0,0 +1,107 @@ + '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 index 00000000..d6d03e9f --- /dev/null +++ b/erp24/controllers/MarkingController.php @@ -0,0 +1,211 @@ + [ + '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 id => name */ + protected function getActiveProducers(): array + { + return Producer::findActive() + ->orderBy(['name' => SORT_ASC]) + ->select(['name', 'id']) + ->indexBy('id') + ->column(); + } + + /** @return array id => name */ + protected function getActiveSuppliers(): array + { + return Supplier::findActive() + ->orderBy(['name' => SORT_ASC]) + ->select(['name', 'id']) + ->indexBy('id') + ->column(); + } + + /** @return array 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 index 00000000..b0a42386 --- /dev/null +++ b/erp24/controllers/ProducerController.php @@ -0,0 +1,282 @@ + [ + '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 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 index 00000000..3f83a227 --- /dev/null +++ b/erp24/controllers/ProductMappingController.php @@ -0,0 +1,335 @@ + [ + '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 id => name */ + protected function getActiveSuppliers(): array + { + return Supplier::findActive() + ->orderBy(['name' => SORT_ASC]) + ->select(['name', 'id']) + ->indexBy('id') + ->column(); + } + + /** + * @return array — плантации с форматом "Название (Страна) — Производитель" + */ + 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 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; + } +} diff --git a/erp24/controllers/SupplierController.php b/erp24/controllers/SupplierController.php index 2743a745..3c9a529f 100644 --- a/erp24/controllers/SupplierController.php +++ b/erp24/controllers/SupplierController.php @@ -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 index 00000000..a94e5b7b --- /dev/null +++ b/erp24/forms/ProductMappingFilterForm.php @@ -0,0 +1,150 @@ + 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 $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 + */ + 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()); + } +} diff --git a/erp24/migrations/m260408_100000_create_suppliers_table.php b/erp24/migrations/m260408_100000_create_suppliers_table.php index c1488b0c..ac63317e 100644 --- a/erp24/migrations/m260408_100000_create_suppliers_table.php +++ b/erp24/migrations/m260408_100000_create_suppliers_table.php @@ -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 index 00000000..9907780f --- /dev/null +++ b/erp24/migrations/m260408_100100_create_producers_table.php @@ -0,0 +1,33 @@ +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 index 00000000..358f3e19 --- /dev/null +++ b/erp24/migrations/m260408_100200_create_plantations_table.php @@ -0,0 +1,58 @@ +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 index 00000000..a5ada6cb --- /dev/null +++ b/erp24/migrations/m260408_100300_create_markings_table.php @@ -0,0 +1,45 @@ +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 index 00000000..4d4feeea --- /dev/null +++ b/erp24/migrations/m260408_100400_create_product_mappings_table.php @@ -0,0 +1,56 @@ +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 index 00000000..391c3a58 --- /dev/null +++ b/erp24/migrations/m260408_100500_create_mapping_markings_table.php @@ -0,0 +1,36 @@ +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 index 00000000..f58d86b8 --- /dev/null +++ b/erp24/migrations/m260409_120000_add_erp300_rbac_permissions.php @@ -0,0 +1,147 @@ +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 index 00000000..4b54bce2 --- /dev/null +++ b/erp24/records/MappingMarking.php @@ -0,0 +1,36 @@ +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 index 00000000..1a4a6183 --- /dev/null +++ b/erp24/records/Plantation.php @@ -0,0 +1,166 @@ + 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 'Активна'; + } + return 'Неактивна'; + } + + /* --- Блокировки --- */ + + /** + * Есть ли активные маркировки, связанные с этой плантацией. + */ + 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 index 00000000..30328389 --- /dev/null +++ b/erp24/records/Producer.php @@ -0,0 +1,159 @@ + 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 'Активен'; + } + return 'Неактивен'; + } + + /* --- Блокировки --- */ + + /** + * Есть ли активные маркировки, связанные с этим производителем + * (прямо через 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 index 00000000..4d278f60 --- /dev/null +++ b/erp24/records/ProducerSearch.php @@ -0,0 +1,54 @@ +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 index 00000000..1f7fc44e --- /dev/null +++ b/erp24/records/ProductMapping.php @@ -0,0 +1,251 @@ +=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() + ); + } +} diff --git a/erp24/records/Supplier.php b/erp24/records/Supplier.php index e58ca559..69b447b8 100644 --- a/erp24/records/Supplier.php +++ b/erp24/records/Supplier.php @@ -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 index 00000000..3ab0a7fb --- /dev/null +++ b/erp24/services/ProductMappingService.php @@ -0,0 +1,496 @@ +, + * 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 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 + */ + 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 + */ + 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 index 00000000..194b1cb1 --- /dev/null +++ b/erp24/tests/unit/commands/ProductMappingCommandTest.php @@ -0,0 +1,63 @@ +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 index 00000000..6d761e4a --- /dev/null +++ b/erp24/tests/unit/controllers/Erp300ControllersTest.php @@ -0,0 +1,116 @@ +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 + */ + 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 index 00000000..d65712ea --- /dev/null +++ b/erp24/tests/unit/forms/ProductMappingFilterFormTest.php @@ -0,0 +1,141 @@ +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 index 00000000..312409af --- /dev/null +++ b/erp24/tests/unit/migrations/Erp300RbacMigrationTest.php @@ -0,0 +1,144 @@ +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 + */ + 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 index 00000000..c06cf0b1 --- /dev/null +++ b/erp24/tests/unit/records/MarkingTest.php @@ -0,0 +1,170 @@ +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 + */ + 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 + */ + 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 index 00000000..c8c19c9c --- /dev/null +++ b/erp24/tests/unit/records/PlantationTest.php @@ -0,0 +1,97 @@ +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 index 00000000..7fc1eb78 --- /dev/null +++ b/erp24/tests/unit/records/ProducerTest.php @@ -0,0 +1,66 @@ +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 index 00000000..af25ee08 --- /dev/null +++ b/erp24/tests/unit/records/ProductMappingTest.php @@ -0,0 +1,127 @@ +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 index 00000000..64d7062c --- /dev/null +++ b/erp24/tests/unit/records/SupplierTest.php @@ -0,0 +1,105 @@ +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 index 00000000..8fcf6aca --- /dev/null +++ b/erp24/tests/unit/services/ProductMappingServiceTest.php @@ -0,0 +1,82 @@ +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()); + } +} diff --git a/erp24/views/buyer-reference/index.php b/erp24/views/buyer-reference/index.php index 330f299e..b5cd03ab 100644 --- a/erp24/views/buyer-reference/index.php +++ b/erp24/views/buyer-reference/index.php @@ -8,36 +8,56 @@ use yii\web\View; $this->title = 'Справочник закупщика'; ?> -

title) ?>

+

title) ?>

-
+ + +
@@ -45,22 +65,22 @@ $this->title = 'Справочник закупщика';
- Загрузка справочника поставщиков... + Загрузка справочника поставщиков...
- Загрузка производителей и плантаций... + Загрузка производителей и плантаций...
- Загрузка маркировок... + Загрузка маркировок...
- Загрузка маппинга товаров... + Загрузка маппинга товаров...
@@ -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 = <<Загрузка...
'); + $.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( + '
' + + '' + msg + + ' ' + + '
' + ); + \$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 index 00000000..cce834e9 --- /dev/null +++ b/erp24/views/marking/_form.php @@ -0,0 +1,137 @@ + $producers */ +/** @var array $plantations */ +/** @var array $suppliers */ +?> + +
+ + +
+ + +
3-12 символов: A-Z, 0-9, дефис
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
ПРЕВЬЮ
+
+ — +
+
+ +
+ + +
+
+ + diff --git a/erp24/views/marking/index.php b/erp24/views/marking/index.php new file mode 100644 index 00000000..103f376f --- /dev/null +++ b/erp24/views/marking/index.php @@ -0,0 +1,263 @@ +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']; + } +} +?> + + + +
+
+ + Справочник маркировок + +
Уникальные коды товаров у производителей +
+ +
+ + 'marking-pjax', 'timeout' => 5000]); ?> + + + + + + + + + + + + + + + + + + + + + + + id] ?? 0; + $isUnlinked = $marking->is_active && $mappingsCount === 0; + $rowClasses = []; + if (!$marking->is_active) { + $rowClasses[] = 'row-inactive'; + } elseif ($isUnlinked) { + $rowClasses[] = 'row-unlinked'; + } + ?> + + + + + + + + + + + + +
КодПроизводительПлантацияНазваниеПоставщикМаппинговСтатусДействия
+ Пока нет ни одной маркировки +
+ code) ?> + producer?->name ?? '—') ?>plantation?->name ?? '—') ?> + product_name) ?> + + Не привязана + + supplier?->name ?? '—') ?> + 0): ?> + + + 0 + + + getStatusBadge() ?> + + + + + is_active): ?> + + + + + + +
+ + + + + + +
'; + + 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('
' + messages[0] + '
'); + }); + } + }, + 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 index 00000000..b05fb2dd --- /dev/null +++ b/erp24/views/producer/_plantation_form.php @@ -0,0 +1,50 @@ + $producers */ +?> + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
diff --git a/erp24/views/producer/_producer_form.php b/erp24/views/producer/_producer_form.php new file mode 100644 index 00000000..cccaf6a4 --- /dev/null +++ b/erp24/views/producer/_producer_form.php @@ -0,0 +1,24 @@ + + +
+ + +
+ + +
+ +
+ + +
+
diff --git a/erp24/views/producer/index.php b/erp24/views/producer/index.php new file mode 100644 index 00000000..78bacc26 --- /dev/null +++ b/erp24/views/producer/index.php @@ -0,0 +1,354 @@ +getModels(); +?> + + + +
+
+ + Производители и плантации + +
Селекционеры/бренды и их фермы +
+
+ + +
+
+ + 'producer-pjax', 'timeout' => 5000]); ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + plantations as $plantation): ?> + + + + + + + + + + + +
ПроизводительПлантацияСтранаСтатусДействия
+ Пока нет ни одного производителя +
+ + name) ?> + + plantations) ?> плантаций + + getStatusBadge() ?> + + + + + is_active): ?> + + + + + + +
+ + name) ?> + + country) ?> + + getStatusBadge() ?> + + + + + is_active): ?> + + + + + + +
+ + + + + + +'; + + // ========== 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('
' + messages[0] + '
'); + }); + } + }, + 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('
' + messages[0] + '
'); + }); + } + }, + 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 index 00000000..1f399f02 --- /dev/null +++ b/erp24/views/product-mapping/_analytics.php @@ -0,0 +1,56 @@ + + +
+
+
+
+
Актуальных
+
+ +
+
+
+
+
+
+
+
С поставщиком
+
+ + + (%) + +
+
+
+
+
+
+
+
Без производителя
+
+ +
+
+ % +
+
+
+
+
+
+
+
1 поставщик
+
+ +
+
+ % +
+
+
+
+
diff --git a/erp24/views/product-mapping/_card.php b/erp24/views/product-mapping/_card.php new file mode 100644 index 00000000..7198b022 --- /dev/null +++ b/erp24/views/product-mapping/_card.php @@ -0,0 +1,102 @@ +Нет поставщика'; +} elseif ($count === 1) { + $cardClass .= ' pm-card-single'; + $borderStyle = 'border:2px solid #ffc107;'; + $stateLabel = 'Только 1 поставщик'; +} else { + $borderStyle = 'border:1px solid #dee2e6;'; +} +?> + +
+ +
+
+
+ + name) ?> + +
+
+ category ?? '—') ?> + subcategory): ?> / subcategory) ?> + species): ?> / species) ?> + sort): ?> / sort) ?> + size): ?> / size ?>см + | GUID: id) ?> +
+
+ +
+ + +
+ + Нет маппинга на поставщиков. Добавьте хотя бы одного. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ПоставщикНазвание у поставщикаПлантацияАртикулШтрихкодКвантМаркировкиДействия
supplier->name ?? '—') ?>supplier_product_name) ?>plantation->name ?? '—') ?>article ?? '') ?>barcode ?? '') ?>quant ?> + markings as $marking): ?> + + code) ?> + + + + + + + + + +
+ +
diff --git a/erp24/views/product-mapping/_filters.php b/erp24/views/product-mapping/_filters.php new file mode 100644 index 00000000..818691fb --- /dev/null +++ b/erp24/views/product-mapping/_filters.php @@ -0,0 +1,127 @@ + $folders1c */ +/** @var array $suppliers */ +/** @var array $markings */ +?> + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+
+ only_without_supplier ? 'checked' : '' ?>> + +
+
+ +
+ +
+
+
+
diff --git a/erp24/views/product-mapping/_form.php b/erp24/views/product-mapping/_form.php new file mode 100644 index 00000000..515f145a --- /dev/null +++ b/erp24/views/product-mapping/_form.php @@ -0,0 +1,98 @@ + $suppliers */ +/** @var array $plantations */ +/** @var array $markings */ +/** @var int[] $selectedMarkingIds */ +?> + +
+ + + + +
+ Товар 1С: name) ?> +
GUID: id) ?> +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ Ctrl+клик для выбора нескольких. + Нет нужной маркировки? + Откройте справочник маркировок + в новой вкладке. +
+
+ +
+ + +
+
diff --git a/erp24/views/product-mapping/index.php b/erp24/views/product-mapping/index.php new file mode 100644 index 00000000..f346b785 --- /dev/null +++ b/erp24/views/product-mapping/index.php @@ -0,0 +1,357 @@ + $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 $folders1c */ +/** @var array $suppliers */ +/** @var array $markings */ +?> + + + +
+
+ + Маппинг товаров 1С + +
+ Товары из номенклатуры 1С и привязка к поставщикам + +
+
+ + + + + Показано: totalCount ?> + +
+
+ +render('_analytics', ['analytics' => $analytics]) ?> + +render('_filters', [ + 'filters' => $filters, + 'categories' => $categories, + 'subcategories' => $subcategories, + 'speciesList' => $speciesList, + 'folders1c' => $folders1c, + 'suppliers' => $suppliers, + 'markings' => $markings, +]) ?> + +
+ +
+ Нет товаров, соответствующих фильтрам +
+ + + + render('_card', [ + 'product' => $product, + 'mappings' => $mappingsByGuid[$product->id] ?? [], + ]) ?> + +
+ +pageCount > 1): ?> +
+ $pagination, + 'options' => ['class' => 'pagination pagination-sm'], + ]) ?> +
+ + + + + +'; + + 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('
Загрузка...
'); + \$.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('').prop('disabled', !category); + $('#pm-filter-species').html('').prop('disabled', true); + if (category) { + \$.get('{$cascadeUrl}', {category: category}, function(data) { + var html = ''; + \$.each(data.subcategories, function(_, sub) { + html += ''; + }); + $('#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('').prop('disabled', !sub); + if (cat && sub) { + \$.get('{$cascadeUrl}', {category: cat, subcategory: sub}, function(data) { + var html = ''; + \$.each(data.species, function(_, sp) { + html += ''; + }); + $('#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('
' + messages[0] + '
'); + }); + } + }, + 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); +?> diff --git a/erp24/views/supplier/_form.php b/erp24/views/supplier/_form.php index d4a95b5d..9a3cf3c7 100644 --- a/erp24/views/supplier/_form.php +++ b/erp24/views/supplier/_form.php @@ -32,7 +32,7 @@ use yii_app\records\Supplier; ]) ?>
- +
@@ -41,7 +41,7 @@ use yii_app\records\Supplier;
diff --git a/erp24/views/supplier/index.php b/erp24/views/supplier/index.php index 9f7fb2d1..f3a8efdf 100644 --- a/erp24/views/supplier/index.php +++ b/erp24/views/supplier/index.php @@ -21,12 +21,12 @@ use yii_app\records\Supplier;
- Справочник поставщиков + Справочник поставщиков
Локальные и международные
@@ -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( - '', + '', '#', [ 'class' => 'btn-supplier-edit', @@ -101,10 +101,10 @@ use yii_app\records\Supplier; }, 'delete' => function ($url, $model) { if (!$model->is_active) { - return ' '; + return ' '; } return ' ' . Html::a( - '', + '', '#', [ 'class' => 'btn-supplier-delete', @@ -127,7 +127,7 @@ use yii_app\records\Supplier; '; + + 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 = 'Превышен'; + } else if (b.is_alert) { + statusBadge = 'Предупреждение'; + } else { + statusBadge = 'Норма'; + } + + var row = '' + + '' + b.period_start + ' — ' + b.period_end + '' + + '' + escapeHtml(b.category) + '' + + '' + formatNumber(b.limit_amount) + '' + + '' + formatNumber(b.used_amount) + '' + + '' + b.usage_pct + '%' + + '' + b.alert_threshold_pct + '%' + + '' + statusBadge + '' + + ''; + + 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);