From d9d9cf68fa404f71b67a721f0f78fb4981e6bbb2 Mon Sep 17 00:00:00 2001 From: marina Date: Fri, 21 Feb 2025 16:37:07 +0300 Subject: [PATCH] =?utf8?q?ERP-302=20=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?utf8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B1=D1=83?= =?utf8?q?=D0=BA=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/controllers/BouquetController.php | 463 ++++-------------- erp24/records/BouquetComposition.php | 304 ++++++++++-- .../BouquetCompositionMatrixTypeHistory.php | 99 ++-- erp24/records/BouquetCompositionProducts.php | 135 ++++- erp24/records/BouquetForecast.php | 124 +++-- erp24/views/bouquet/create.php | 1 - erp24/web/js/bouquet/bouquet.js | 2 +- erp24/widgets/DualList.php | 4 +- 8 files changed, 626 insertions(+), 506 deletions(-) diff --git a/erp24/controllers/BouquetController.php b/erp24/controllers/BouquetController.php index 4a8ff53b..7a3d3b3b 100644 --- a/erp24/controllers/BouquetController.php +++ b/erp24/controllers/BouquetController.php @@ -4,55 +4,29 @@ namespace app\controllers; use Exception; use Yii; -use yii\helpers\ArrayHelper; use yii\helpers\Url; use yii\web\Controller; use yii\web\NotFoundHttpException; use yii\web\Response; -use yii\web\UploadedFile; use yii_app\helpers\DataHelper; -use yii_app\records\BouquetComposition; -use yii_app\records\BouquetCompositionMatrixTypeHistory; -use yii_app\records\BouquetCompositionProducts; -use yii_app\records\BouquetForecast; -use yii_app\records\CityStore; -use yii_app\records\Files; -use yii_app\records\MatrixType; -use yii_app\records\Products1c; -use yii_app\records\Products1cNomenclature; -use yii_app\records\StoreType; -use yii_app\services\FileService; +use yii_app\records\{BouquetComposition, + BouquetCompositionProducts, + BouquetForecast, + CityStore, + Files, + MatrixType, + StoreType}; -/** - * Контроллер для управления букетами и их составами. - */ class BouquetController extends Controller { public function actionIndex() { - $query = BouquetComposition::find()->orderBy('id desc'); $request = Yii::$app->request; - $year = $request->get('year'); - $month = $request->get('month'); - $matrixTypeId = $request->get('matrix_type_id'); + $query = BouquetComposition::find() + ->orderBy(['id' => SORT_DESC]) + ->groupBy('bouquet_composition.id'); - if ($year || $month) { - $query->joinWith('bouquetForecast as bf'); - } - if ($year) { - $query->andWhere(['bf.year' => $year]); - } - if ($month) { - $query->andWhere(['bf.month' => $month]); - } - - if ($matrixTypeId) { - $query->leftJoin('bouquet_composition_matrix_type_history mth', 'mth.bouquet_id = bouquet_composition.id'); - $query->andWhere(['mth.matrix_type_id' => $matrixTypeId, 'mth.is_active' => BouquetCompositionMatrixTypeHistory::IS_ACTIVE]); - } - - $query->groupBy('bouquet_composition.id') - ->orderBy('bouquet_composition.id desc'); + BouquetComposition::applyFilters($query, $request); $dataProvider = new \yii\data\ActiveDataProvider([ 'query' => $query, @@ -61,373 +35,117 @@ class BouquetController extends Controller return $this->render('index', [ 'dataProvider' => $dataProvider, - 'matrix_type' => $matrixTypeId ? MatrixType::findOne($matrixTypeId)?->name : null, + 'matrix_type' => $request->get('matrix_type_id') + ? MatrixType::findOne($request->get('matrix_type_id'))?->name + : null, ]); } public function actionCreate() { - $model = new BouquetComposition(); - $errors = []; - $flash = null; - - if (Yii::$app->request->isPost) { - $data = Yii::$app->request->post(); - $model->guid = DataHelper::createGuidMy('07'); - $model->load($data); - if ($model->save()) { - $month = $data['month']; - $year = $data['year']; - - if ($data['matrix_type_id']) { - if (!empty(BouquetCompositionMatrixTypeHistory::setData($data['matrix_type_id'], $model->id))) { - $errors = array_merge($errors, BouquetCompositionMatrixTypeHistory::setData($data['matrix_type_id'], $model->id)); - } - } - - $model->photo_bouquet = UploadedFile::getInstances($model, 'photo_bouquet'); - if (!empty($model->photo_bouquet)) { - Files::deleteAll(['file_type' => 'image', 'entity_id' => $model->id, 'entity' => BouquetComposition::PHOTO_BOUQUET]); - foreach ($model->photo_bouquet as $photo) { - FileService::saveUploadedFile($photo, BouquetComposition::PHOTO_BOUQUET, $model->id); - } - } - - $model->video_presentation = UploadedFile::getInstances($model, 'video_presentation'); - if (!empty($model->video_presentation)) { - Files::deleteAll(['file_type' => 'video', 'entity_id' => $model->id, 'entity' => BouquetComposition::VIDEO_PRESENTATION]); - foreach ($model->video_presentation as $video) { - FileService::saveUploadedFile($video, BouquetComposition::VIDEO_PRESENTATION, $model->id); - } - } - - $model->video_build_process = UploadedFile::getInstances($model, 'video_build_process'); - if (!empty($model->video_build_process)) { - Files::deleteAll(['file_type' => 'video', 'entity_id' => $model->id, 'entity' => BouquetComposition::VIDEO_BUILD_PROCESS]); - foreach ($model->video_build_process as $video) { - FileService::saveUploadedFile($video, BouquetComposition::VIDEO_BUILD_PROCESS, $model->id); - } - } - if (!empty($data['BouquetForecast']['type_sales_value'])) { - $salesData = $data['BouquetForecast']['type_sales_value']; - $errors = []; - - if (!empty($salesData['offline'])) { - $offlineErrors = BouquetForecast::processSalesData($model->id, $year, $month, $salesData['offline'], BouquetForecast::OFFLINE_STORES); - if (!empty($offlineErrors)) { - $errors = array_merge($errors, $offlineErrors); - } - } - - if (!empty($salesData['online'])) { - $onlineErrors = BouquetForecast::processSalesData($model->id, $year, $month, $salesData['online'], BouquetForecast::ONLINE_STORES); - if (!empty($onlineErrors)) { - $errors = array_merge($errors, $onlineErrors); - } - } - - if (!empty($salesData['marketplace'])) { - $marketplaceErrors = BouquetForecast::processSalesData($model->id, $year, $month, $salesData['marketplace'], BouquetForecast::MARKETPLACE); - if (!empty($marketplaceErrors)) { - $errors = array_merge($errors, $marketplaceErrors); - } - } - - if (!empty($errors)) { - Yii::$app->session->setFlash('danger', implode(' ', array_map('json_encode', $errors))); - } - } - - if (array_key_exists('products_quantity', $data)) { - BouquetCompositionProducts::deleteAll(['bouquet_id' => $model->id]); - $bouquetProducts = Yii::$app->request->post('products_quantity'); - foreach ($bouquetProducts as $key => $value) { - $product = new BouquetCompositionProducts([ - 'bouquet_id' => $model->id, - 'product_guid' => $key, - 'count' => $value - ]); - if (!$product->save()) { - $errors = array_merge($errors, $product->getErrors()); - } - } - } - Yii::$app->session->setFlash('success', 'Данные успешно сохранены'); - return $this->redirect(['view', 'id' => $model->id]); - } else { - $errors = $model->getErrors(); - } - } - - $availableItems = ArrayHelper::map( - Products1c::find() - ->where([ - 'view' => Products1c::IS_VISIBLE, - 'tip' => Products1c::TYPE_PRODUCTS - ]) - ->all(), - 'id', - 'name' - ); - $storesTypeList = BouquetForecast::getStoresList(null, BouquetForecast::OFFLINE_STORES, StoreType::class, []); - $marketplaceList = BouquetForecast::getStoresList(null, BouquetForecast::MARKETPLACE, CityStore::class, ['visible' => CityStore::IS_VISIBLE]); - $onlineStoresList = BouquetForecast::getStoresList(null, BouquetForecast::ONLINE_STORES, CityStore::class, ['visible' => CityStore::IS_VISIBLE]); - - if (!empty($errors)) { - $flatErrors = array_merge([], ...array_values($errors)); - $flashMessage = !empty($flatErrors) ? implode(' ', $flatErrors) : 'Произошла ошибка.'; - Yii::$app->session->setFlash('danger', $flashMessage); - } - - return $this->render('create', [ - 'onlineStoresList' => $onlineStoresList, - 'marketplaceList' => $marketplaceList, - 'storesTypeList' => $storesTypeList, - 'availableItems' => $availableItems, - 'flash' => $flash - ]); - + return $this->handleCreateOrUpdate(new BouquetComposition()); } public function actionView($id) { - $model = BouquetComposition::findOne($id); - $errors = []; - $flash = null; - + $model = BouquetComposition::findModel($id); if (Yii::$app->request->isPost) { - $data = Yii::$app->request->post(); - $model->load($data); - if ($model->save()) { - - $month = $data['month']; - $year = $data['year']; - - if ($data['matrix_type_id']) { - if (!empty(BouquetCompositionMatrixTypeHistory::setData($data['matrix_type_id'], $model->id))) { - $errors = array_merge($errors, BouquetCompositionMatrixTypeHistory::setData($data['matrix_type_id'], $model->id)); - } - } - - $model->photo_bouquet = UploadedFile::getInstances($model, 'photo_bouquet'); - if (!empty($model->photo_bouquet)) { - Files::deleteAll(['file_type' => 'image', 'entity_id' => $model->id, 'entity' => BouquetComposition::PHOTO_BOUQUET]); - foreach ($model->photo_bouquet as $photo) { - FileService::saveUploadedFile($photo, BouquetComposition::PHOTO_BOUQUET, $model->id); - } - } - - $model->video_presentation = UploadedFile::getInstances($model, 'video_presentation'); - if (!empty($model->video_presentation)) { - Files::deleteAll(['file_type' => 'video', 'entity_id' => $model->id, 'entity' => BouquetComposition::VIDEO_PRESENTATION]); - foreach ($model->video_presentation as $video) { - FileService::saveUploadedFile($video, BouquetComposition::VIDEO_PRESENTATION, $model->id); - } - } - - $model->video_build_process = UploadedFile::getInstances($model, 'video_build_process'); - if (!empty($model->video_build_process)) { - Files::deleteAll(['file_type' => 'video', 'entity_id' => $model->id, 'entity' => BouquetComposition::VIDEO_BUILD_PROCESS]); - foreach ($model->video_build_process as $video) { - FileService::saveUploadedFile($video, BouquetComposition::VIDEO_BUILD_PROCESS, $model->id); - } - } - - - if (!empty($data['BouquetForecast']['type_sales_value'])) { - $salesData = $data['BouquetForecast']['type_sales_value']; - $errors = []; - - if (!empty($salesData['offline'])) { - $offlineErrors = BouquetForecast::processSalesData($model->id, $year, $month, $salesData['offline'], BouquetForecast::OFFLINE_STORES); - if (!empty($offlineErrors)) { - $errors = array_merge($errors, $offlineErrors); - } - } - - if (!empty($salesData['online'])) { - $onlineErrors = BouquetForecast::processSalesData($model->id, $year, $month, $salesData['online'], BouquetForecast::ONLINE_STORES); - if (!empty($onlineErrors)) { - $errors = array_merge($errors, $onlineErrors); - } - } - - if (!empty($salesData['marketplace'])) { - $marketplaceErrors = BouquetForecast::processSalesData($model->id, $year, $month, $salesData['marketplace'], BouquetForecast::MARKETPLACE); - if (!empty($marketplaceErrors)) { - $errors = array_merge($errors, $marketplaceErrors); - } - } - - if (!empty($errors)) { - Yii::$app->session->setFlash('danger', implode(' ', array_map('json_encode', $errors))); - } - } - - Yii::$app->session->setFlash('success', 'Данные успешно сохранены'); - } else { - $errors = $model->getErrors(); - } + return $this->handleCreateOrUpdate($model); } + return $this->renderView($model); + } - $photoFiles = Files::find()->where(['entity_id' => $id, 'file_type' => 'image', 'entity' => BouquetComposition::PHOTO_BOUQUET])->all(); - $videoFiles = Files::find()->where(['entity_id' => $id, 'file_type' => 'image', 'entity' => BouquetComposition::VIDEO_PRESENTATION])->all(); - $processFiles = Files::find()->where(['entity_id' => $id, 'file_type' => 'image', 'entity' => BouquetComposition::VIDEO_BUILD_PROCESS])->all(); - - $photoUrls = array_map(fn($file) => Url::to([$file->url], true), $photoFiles); - $videoUrls = array_map(fn($file) => Url::to([$file->url]), $videoFiles); - $processUrls = array_map(fn($file) => Url::to([$file->url]), $processFiles); - - $bouquetCompositionProducts = BouquetCompositionProducts::find()->andWhere(['bouquet_id' => $id])->all(); - $storesTypeList = BouquetForecast::getStoresList($id, BouquetForecast::OFFLINE_STORES, StoreType::class, []); - $marketplaceList = BouquetForecast::getStoresList($id, BouquetForecast::MARKETPLACE, CityStore::class, ['visible' => CityStore::IS_VISIBLE]); - $onlineStoresList = BouquetForecast::getStoresList($id, BouquetForecast::ONLINE_STORES, CityStore::class, ['visible' => CityStore::IS_VISIBLE]); - + public function actionUpdate($id) + { + $model = BouquetComposition::findModel($id); - if (!empty($errors)) { - $flatErrors = array_merge([], ...array_values($errors)); - $flashMessage = !empty($flatErrors) ? implode(' ', $flatErrors) : 'Произошла ошибка.'; - Yii::$app->session->setFlash('danger', $flashMessage); + if (Yii::$app->request->isPost && $products = Yii::$app->request->post('products_quantity')) { + BouquetCompositionProducts::updateProducts($model->id, $products); + return $this->redirect(['view', 'id' => $id]); } - - return $this->render('view', [ + return $this->render('update', [ 'model' => $model, - 'onlineStoresList' => $onlineStoresList, - 'bouquetCompositionProducts' => $bouquetCompositionProducts, - 'marketplaceList' => $marketplaceList, - 'storesTypeList' => $storesTypeList, - 'photoUrls' => $photoUrls, - 'photoFiles' => $photoFiles, - 'videoUrls' => $videoUrls, - 'processUrls' => $processUrls, - 'flash' => $flash - ]); - } - - - /** - * Сохранение записи в таблицу files - */ - private function saveFileRecord($bouquetId, $filePath, $type) - { - $file = new Files([ - 'url' => $filePath, - 'file_type' => $type, - 'created_at' => time(), - 'entity_id' => $bouquetId, - 'entity' => 'bouquet/photo', + 'selectedItems' => BouquetCompositionProducts::getSelectedItems($model->id), + 'availableItems' => BouquetCompositionProducts::getAvailableItems($model->isNewRecord, $model->id), ]); - $file->save(); } - public function actionUpdate($id) + private function handleCreateOrUpdate(BouquetComposition $model) { - $model = BouquetComposition::findOne($id); - - if (!$model) { - throw new NotFoundHttpException('Букет не найден.'); + $request = Yii::$app->request; + if (!$request->isPost) { + return $this->renderCreate($model); } - if (Yii::$app->request->isPost) { - try { - if (Yii::$app->request->post('products_quantity')) { - BouquetCompositionProducts::deleteAll(['bouquet_id' => $model->id]); - $bouquetProducts = Yii::$app->request->post('products_quantity'); - foreach ($bouquetProducts as $key => $value) { - $product = new BouquetCompositionProducts([ - 'bouquet_id' => $id, - 'product_guid' => $key, - 'count' => $value - ]); - $product->save(); - } + $data = $request->post(); + $isNew = $model->isNewRecord; - return $this->redirect(['view', 'id' => $id]); - } - } catch (Exception $exception) { - throw new NotFoundHttpException($exception->getMessage()); - } + if ($isNew) { + $model->guid = DataHelper::createGuidMy('07'); } - $products = BouquetCompositionProducts::find() - ->where(['bouquet_id' => $model->id]) - ->with('product') - ->all(); + if (isset($data['matrix_type_id'])) { + $model->matrix_type_id = (int) $data['matrix_type_id']; + } - $selectedItems = array_map(fn($product) => [ - 'id' => $product->product_guid, - 'count' => $product->count, - 'text' => $product->product->name ?? '' - ], $products); + $transaction = Yii::$app->db->beginTransaction(); + try { + if (!$model->load($data) || !$model->validate() || !$model->save()) { + throw new Exception('Ошибка при сохранении букета'); + } - $selectedProductIds = array_column($selectedItems, 'id'); + $model->processRelations($data); + $transaction->commit(); + Yii::$app->session->setFlash('success', 'Данные успешно сохранены'); + return $this->redirect(['view', 'id' => $model->id]); + } catch (Exception $e) { + $transaction->rollBack(); + $model->setErrorFlash($model->getErrors() ?: [$e->getMessage()]); + return $isNew ? $this->renderCreate($model) : $this->renderView($model); + } + } - $availableItems = ArrayHelper::map( - Products1c::find() - ->where([ - 'view' => Products1c::IS_VISIBLE, - 'tip' => Products1c::TYPE_PRODUCTS - ]) - ->andWhere(['not in', 'id', $selectedProductIds]) - ->all(), - 'id', - 'name' - ); - return $this->render('update', [ - 'model' => $model, - 'selectedItems' => $selectedItems, - 'availableItems' => $availableItems, + private function renderCreate(BouquetComposition $model) + { + return $this->render('create', [ + 'onlineStoresList' => BouquetForecast::getStoresList(null, BouquetForecast::ONLINE_STORES, CityStore::class, ['visible' => CityStore::IS_VISIBLE]), + 'marketplaceList' => BouquetForecast::getStoresList(null, BouquetForecast::MARKETPLACE, CityStore::class, ['visible' => CityStore::IS_VISIBLE]), + 'storesTypeList' => BouquetForecast::getStoresList(null, BouquetForecast::OFFLINE_STORES, StoreType::class, []), + 'availableItems' => BouquetCompositionProducts::getAvailableItems($model->isNewRecord, $model->id), ]); } - public function actionUpload() + private function renderView(BouquetComposition $model) { - $model = new BouquetComposition(); + $files = $model->getFiles(); - if (Yii::$app->request->isPost) { - $model->photo_id = UploadedFile::getInstance($model, 'photo_id'); - if ($model->validate() && $model->upload()) { - // Логика для сохранения - } - } - - return $this->render('upload', ['model' => $model]); + return $this->render('view', [ + 'model' => $model, + 'onlineStoresList' => BouquetForecast::getStoresList($model->id, BouquetForecast::ONLINE_STORES, CityStore::class, ['visible' => CityStore::IS_VISIBLE]), + 'marketplaceList' => BouquetForecast::getStoresList($model->id, BouquetForecast::MARKETPLACE, CityStore::class, ['visible' => CityStore::IS_VISIBLE]), + 'storesTypeList' => BouquetForecast::getStoresList($model->id, BouquetForecast::OFFLINE_STORES, StoreType::class, []), + 'bouquetCompositionProducts' => BouquetCompositionProducts::getCompositionProducts($model->id), + 'photoUrls' => array_map(fn($file) => Url::to([$file->url], true), $files['photo']), + 'photoFiles' => $files['photo'], + 'videoUrls' => array_map(fn($file) => Url::to([$file->url]), $files['video']), + 'processUrls' => array_map(fn($file) => Url::to([$file->url]), $files['process']), + ]); } - public function actionGetList() + public function actionGetCalculates() { - \Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; - - $request = Yii::$app->request->post(); - - $conditions = array_filter([ - 'type-num' => $request['type-num'] ?? null, - 'color' => $request['color'] ?? null, - 'species' => $request['species'] ?? null, - 'category' => $request['category'] ?? null, - 'size' => $request['size'] ?? null, - ]); - - $subqueries = []; - foreach ($conditions as $field => $value) { - $subqueries[] = Products1cNomenclature::find()->select('id')->where([$field => $value]); - } - - $query = Products1c::find()->where([ - 'tip' => Products1c::TYPE_PRODUCTS, - 'view' => Products1c::IS_VISIBLE, - ]); - - if (!empty($subqueries)) { - foreach ($subqueries as $subquery) { - $query->andWhere(['in', 'id', $subquery]); - } - } + Yii::$app->response->format = Response::FORMAT_JSON; + $data = json_decode(Yii::$app->request->getRawBody(), true); + $model = new BouquetComposition(); - return ArrayHelper::map($query->asArray()->all(), 'id', 'name'); + return [ + 'selfcost' => round($model->getSelfCost($data), 2), + 'cost' => round($model->getCost($data), 2), + 'markup' => $model->getMarkUp($data), + ]; } public function actionGetSalesData() @@ -454,18 +172,5 @@ class BouquetController extends Controller ]; } - public function actionGetCalculates() - { - Yii::$app->response->format = Response::FORMAT_JSON; - $data = json_decode(Yii::$app->request->getRawBody(), true); - $model = new BouquetComposition(); - - return [ - 'selfcost' => round($model->getSelfCost($data), 2), - 'cost' => round($model->getCost($data), 2), - 'markup' => $model->getMarkUp($data), - ]; - } - -} +} \ No newline at end of file diff --git a/erp24/records/BouquetComposition.php b/erp24/records/BouquetComposition.php index 12320e4d..30cc9b94 100644 --- a/erp24/records/BouquetComposition.php +++ b/erp24/records/BouquetComposition.php @@ -2,12 +2,14 @@ namespace yii_app\records; +use Exception; use Yii; use yii\behaviors\BlameableBehavior; use yii\behaviors\TimestampBehavior; use yii\db\ActiveRecord; use yii\db\Expression; -use yii_app\records\BouquetCompositionProducts; +use yii\web\UploadedFile; +use yii_app\services\FileService; /** * This is the model class for table "erp24.bouquet_composition". @@ -21,23 +23,40 @@ use yii_app\records\BouquetCompositionProducts; * @property int|null $updated_by * * @property BouquetCompositionProducts[] $bouquetCompositionProducts + * @property BouquetCompositionMatrixTypeHistory $matrixType + * @property Files $presentation + * @property Files $buildProcess + * @property BouquetForecast[] $bouquetForecast + * + * @property UploadedFile[] $photo_bouquet + * @property UploadedFile[] $video_presentation + * @property UploadedFile[] $video_build_process */ class BouquetComposition extends ActiveRecord { - public $photo_bouquet; - public $video_presentation; - public $video_build_process; - + public const PHOTO_TYPE = 'image'; + public const VIDEO_TYPE = 'video'; public const PHOTO_BOUQUET = 'bouquet/photo_bouquet'; public const VIDEO_PRESENTATION = 'bouquet/video_presentation'; public const VIDEO_BUILD_PROCESS = 'bouquet/video_build_process'; - public static function tableName() + public $photo_bouquet; + public $matrix_type_id; + public $video_presentation; + public $video_build_process; + + /** + * {@inheritdoc} + */ + public static function tableName(): string { return 'erp24.bouquet_composition'; } - public function behaviors() + /** + * {@inheritdoc} + */ + public function behaviors(): array { return [ [ @@ -54,29 +73,33 @@ class BouquetComposition extends ActiveRecord ]; } - - public function rules() + /** + * {@inheritdoc} + */ + public function rules(): array { return [ - [['name'], 'required', 'message' => 'Поле "Название" не может быть пустым.'], - [['created_by', 'updated_by'], 'integer'], + [['matrix_type_id'], 'required', 'message' => 'Поле "Тип Матрицы" не может быть пустым'], + [['name'], 'required', 'message' => 'Поле "Название" не может быть пустым'], + [['created_by', 'updated_by'], 'integer', 'message' => '{attribute} должен быть целым числом'], [['created_at', 'updated_at'], 'safe'], - [['guid', 'name'], 'string', 'max' => 255], - + [['guid', 'name'], 'string', 'max' => 255, 'tooLong' => '{attribute} не должен превышать 255 символов'], [['photo_bouquet'], 'file', 'extensions' => 'jpg, jpeg, png, gif', 'maxFiles' => 10, - 'message' => 'Допустимые форматы: jpg, jpeg, png, gif. Максимум 10 файлов.' + 'message' => 'Допустимые форматы: jpg, jpeg, png, gif. Максимум 10 файлов', ], - [['video_presentation', 'video_build_process'], 'file', 'extensions' => 'mkv, mov, avi, mp4', - 'message' => 'Допустимые форматы видео: mkv, mov, avi, mp4.' + 'message' => 'Допустимые форматы видео: mkv, mov, avi, mp4', ], ]; } - public function attributeLabels() + /** + * {@inheritdoc} + */ + public function attributeLabels(): array { return [ 'id' => 'ID', @@ -89,33 +112,210 @@ class BouquetComposition extends ActiveRecord ]; } + /** + * Поиск модели по ID. + * + * @param int $id ID букета + * @return self + * @throws \yii\web\NotFoundHttpException Если букет не найден + */ + public static function findModel(int $id): self + { + if ($model = static::findOne($id)) { + return $model; + } + throw new \yii\web\NotFoundHttpException('Букет не найден.'); + } + + /** + * Применяет фильтры к запросу букетов. + * + * @param \yii\db\ActiveQuery $query Запрос для фильтрации + * @param \yii\web\Request $request HTTP-запрос с параметрами фильтрации + */ + public static function applyFilters($query, $request): void + { + $year = $request->get('year'); + $month = $request->get('month'); + + if ($year || $month) { + $query->joinWith('bouquetForecast as bf'); + if ($year) { + $query->andWhere(['bf.year' => $year]); + } + if ($month) { + $query->andWhere(['bf.month' => $month]); + } + } + + if ($matrixTypeId = $request->get('matrix_type_id')) { + $query->leftJoin( + 'erp24.bouquet_composition_matrix_type_history mth', // Добавили схему + 'mth.bouquet_id = bouquet_composition.id' + )->andWhere([ + 'mth.matrix_type_id' => $matrixTypeId, + 'mth.is_active' => true // Используем true без константы + ]); + } + } + + /** + * Обрабатывает связанные данные букета (матрицы, файлы, продажи, продукты). + * + * @param array $data Данные из формы + */ + public function processRelations(array $data): void + { + if (!$this->id) { + throw new Exception("ID букета не установлен при обработке связей."); + } + + if (!isset($data['matrix_type_id'])) { + throw new Exception('Тип Матрицы обязателен.'); + } + + BouquetCompositionMatrixTypeHistory::updateMatrixType( + $this->id, + isset($data['matrix_type_id']) ? (int) $data['matrix_type_id'] : null + ); + + $this->processFiles(); + + // Проверяем, передаётся ли id букета + if (!empty($data)) { + BouquetForecast::processSalesData($this->id, $data); + } + + if (!empty($data['products_quantity'])) { + BouquetCompositionProducts::updateProducts($this->id, $data['products_quantity']); + } + } + + /** + * Обрабатывает загрузку файлов (фото и видео). + */ + public function processFiles(): void + { + $fileTypes = [ + 'photo_bouquet' => [self::PHOTO_TYPE, self::PHOTO_BOUQUET], + 'video_presentation' => [self::VIDEO_TYPE, self::VIDEO_PRESENTATION], + 'video_build_process' => [self::VIDEO_TYPE, self::VIDEO_BUILD_PROCESS], + ]; + + foreach ($fileTypes as $attribute => [$type, $entity]) { + $files = UploadedFile::getInstances($this, $attribute); + if ($files) { + Files::deleteAll(['file_type' => $type, 'entity_id' => $this->id, 'entity' => $entity]); + foreach ($files as $file) { + FileService::saveUploadedFile($file, $entity, $this->id); + } + } + } + } + + /** + * Возвращает связанные файлы букета. + * + * @return array Ассоциативный массив файлов (photo, video, process) + */ + public function getFiles(): array + { + return [ + 'photo' => Files::find()->where(['entity_id' => $this->id, 'file_type' => self::PHOTO_TYPE, 'entity' => self::PHOTO_BOUQUET])->all(), + 'video' => Files::find()->where(['entity_id' => $this->id, 'file_type' => self::VIDEO_TYPE, 'entity' => self::VIDEO_PRESENTATION])->all(), + 'process' => Files::find()->where(['entity_id' => $this->id, 'file_type' => self::VIDEO_TYPE, 'entity' => self::VIDEO_BUILD_PROCESS])->all(), + ]; + } + + /** + * Устанавливает сообщение об ошибке во флэш. + * + * @param array $errors Массив ошибок + */ + public function setErrorFlash(array $errors): void + { + $message = 'Произошла ошибка.'; + + if (!empty($errors)) { + $flattenedErrors = []; + + foreach ($errors as $error) { + if (is_array($error)) { + $flattenedErrors = array_merge($flattenedErrors, $error); + } elseif (is_string($error)) { + $flattenedErrors[] = $error; + } + } + + $message = implode(' ', $flattenedErrors); + } + + Yii::$app->session->setFlash('danger', $message); + } + + + /** + * Возвращает связанные продукты букета. + * + * @return \yii\db\ActiveQuery + */ public function getBouquetCompositionProducts() { return $this->hasMany(BouquetCompositionProducts::class, ['bouquet_id' => 'id']); } + /** + * Возвращает активную запись истории типа матрицы. + * + * @return \yii\db\ActiveQuery + */ public function getMatrixType() { return $this->hasOne(BouquetCompositionMatrixTypeHistory::class, ['bouquet_id' => 'id']) ->andWhere(['is_active' => BouquetCompositionMatrixTypeHistory::IS_ACTIVE]); } + /** + * Возвращает видео-презентацию букета. + * + * @return \yii\db\ActiveQuery + */ public function getPresentation() { return $this->hasOne(Files::class, ['entity_id' => 'id']) ->andWhere(['entity' => self::VIDEO_PRESENTATION]); } + /** + * Возвращает видео процесса сборки букета. + * + * @return \yii\db\ActiveQuery + */ public function getBuildProcess() { return $this->hasOne(Files::class, ['entity_id' => 'id']) - ->andWhere(['entity' => self::VIDEO_BUILD_PROCESS]); + ->andWhere(['entity' => self::VIDEO_BUILD_PROCESS]); } - public function getCost($data = null) + /** + * Возвращает прогнозы продаж букета. + * + * @return \yii\db\ActiveQuery + */ + public function getBouquetForecast() { - $cost = 0; + return $this->hasMany(BouquetForecast::class, ['bouquet_id' => 'id']); + } + /** + * Рассчитывает стоимость букета. + * + * @param array|null $data Данные продуктов (опционально) + * @return float Стоимость + */ + public function getCost(?array $data = null): float + { + $cost = 0; $compositionProducts = $this->bouquetCompositionProducts; if (!$compositionProducts) { @@ -143,19 +343,34 @@ class BouquetComposition extends ActiveRecord return $cost; } - public function getMarkUp($data = null) + /** + * Рассчитывает наценку на букет. + * + * @param array|null $data Данные продуктов (опционально) + * @return string Наценка в формате "+X.XX% / +X.XX" + */ + public function getMarkUp(?array $data = null): string { - if ($this->getSelfCost($data) == 0 || $this->getCost($data) == 0) - return 0; + $selfCost = $this->getSelfCost($data); + $cost = $this->getCost($data); + + if ($selfCost == 0 || $cost == 0) { + return '0'; + } return sprintf("+%.2f%% / +%.2f", - (self::getCost($data) / self::getSelfCost($data) - 1) * 100, - self::getCost($data) - self::getSelfCost($data) + ($cost / $selfCost - 1) * 100, + $cost - $selfCost ); } - - public function getSelfCost($data = null) + /** + * Рассчитывает себестоимость букета. + * + * @param array|null $data Данные продуктов (опционально) + * @return float Себестоимость + */ + public function getSelfCost(?array $data = null): float { $selfCost = 0; $compositionProducts = $this->bouquetCompositionProducts; @@ -166,9 +381,8 @@ class BouquetComposition extends ActiveRecord return $selfCost; } } - - $productGuids = array_filter(array_column($compositionProducts, 'product_guid')); - + + $productGuids = array_filter(array_column($compositionProducts, 'product_guid')); if (empty($productGuids)) { return $selfCost; } @@ -192,7 +406,6 @@ class BouquetComposition extends ActiveRecord } $prices = $pricesByProduct[$item['product_guid']] ?? []; - $count = count($prices); if ($count == 0) { @@ -211,25 +424,30 @@ class BouquetComposition extends ActiveRecord return $selfCost; } - public function getBouquetForecast() + /** + * Возвращает список доступных годов для выбора. + * + * @return array Массив годов в формате [год => год] + */ + public static function getYears(): array { - return $this->hasMany(BouquetForecast::class, ['bouquet_id' => 'id']); - } - - public static function getYears() - { - $currentYear = date('Y'); + $currentYear = (int)date('Y'); $years = range($currentYear - 5, $currentYear + 5); return array_combine($years, $years); } - public static function disabledButtons($isCreate = false) + /** + * Проверяет, должны ли кнопки быть отключены. + * + * @param bool $isCreate Флаг создания нового букета + * @return bool True, если кнопки должны быть отключены + */ + public static function disabledButtons(bool $isCreate = false): bool { + return false; if ($isCreate) { return false; } - - return date('d') > 10; + return (int)date('d') > 10; } - -} +} \ No newline at end of file diff --git a/erp24/records/BouquetCompositionMatrixTypeHistory.php b/erp24/records/BouquetCompositionMatrixTypeHistory.php index 316aca9f..50d3c481 100644 --- a/erp24/records/BouquetCompositionMatrixTypeHistory.php +++ b/erp24/records/BouquetCompositionMatrixTypeHistory.php @@ -1,6 +1,8 @@ '{attribute} должен быть целым числом'], + [['date_from', 'date_to', 'created_at', 'updated_at'], 'safe'], + [['is_active'], 'boolean', 'message' => '{attribute} должен быть логическим значением'], + [['bouquet_id', 'matrix_type_id'], 'required', 'message' => '{attribute} обязателен для заполнения'], ]; } /** * {@inheritdoc} */ - public function attributeLabels() + public function attributeLabels(): array { return [ 'id' => 'ID', @@ -82,23 +88,56 @@ class BouquetCompositionMatrixTypeHistory extends ActiveRecord ]; } - public static function setData($value, $bouquetId) { - if (self::findOne(['bouquet_id' => $bouquetId])) { - BouquetCompositionMatrixTypeHistory::updateAll(['date_to' => date('Y-m-d H:i:s'), 'bouquet_id' => $bouquetId, 'date_from' => null, 'is_active' => self::IS_ACTIVE]); + /** + * Обновляет или создает запись истории типа матрицы для букета. + * + * @param int $bouquetId ID букета + * @param int|null $matrixTypeId ID типа матрицы + * @throws Exception Если сохранение не удалось + */ + public static function updateMatrixType(int $bouquetId, ?int $matrixTypeId): void + { + if (!$matrixTypeId) { + return; // Если тип матрицы не указан, ничего не делаем } - $matrixHistoryType = new BouquetCompositionMatrixTypeHistory([ - 'bouquet_id' => $bouquetId, - 'matrix_type_id' => $value, - 'date_from' => new Expression('now()'), - ]); - if (!$matrixHistoryType->save()) { - return $matrixHistoryType->errors(); + $transaction = Yii::$app->db->beginTransaction(); + try { + // Деактивируем текущую активную запись + static::updateAll( + [ + 'date_to' => new Expression('NOW()'), + 'is_active' => self::IS_INACTIVE, + ], + [ + 'bouquet_id' => $bouquetId, + 'is_active' => self::IS_ACTIVE, + ] + ); + + // Создаем новую запись + $matrixHistoryType = new self([ + 'bouquet_id' => $bouquetId, + 'matrix_type_id' => $matrixTypeId, + 'date_from' => new Expression('NOW()'), + 'is_active' => self::IS_ACTIVE, + ]); + + if (!$matrixHistoryType->save()) { + throw new Exception(implode(' ', $matrixHistoryType->getFirstErrors())); + } + + $transaction->commit(); + } catch (Exception $e) { + $transaction->rollBack(); + throw $e; } } /** - * Отношение к букету + * Отношение к букету. + * + * @return \yii\db\ActiveQuery */ public function getBouquet() { @@ -106,10 +145,12 @@ class BouquetCompositionMatrixTypeHistory extends ActiveRecord } /** - * Отношение к типу матрицы + * Отношение к типу матрицы. + * + * @return \yii\db\ActiveQuery */ public function getMatrixType() { return $this->hasOne(MatrixType::class, ['id' => 'matrix_type_id']); } -} +} \ No newline at end of file diff --git a/erp24/records/BouquetCompositionProducts.php b/erp24/records/BouquetCompositionProducts.php index 88c98581..14179d59 100644 --- a/erp24/records/BouquetCompositionProducts.php +++ b/erp24/records/BouquetCompositionProducts.php @@ -1,4 +1,5 @@ '{attribute} обязателен для заполнения'], + [['bouquet_id', 'created_by', 'updated_by'], 'integer', 'message' => '{attribute} должен быть целым числом'], + [['count'], 'number', 'message' => '{attribute} должен быть числом'], [['created_at', 'updated_at'], 'safe'], - [['product_guid'], 'string', 'max' => 255], - [['bouquet_id'], 'exist', 'skipOnError' => true, 'targetClass' => BouquetComposition::class, 'targetAttribute' => ['bouquet_id' => 'id']], + [['product_guid'], 'string', 'max' => 255, 'tooLong' => '{attribute} не должен превышать 255 символов'], + [['bouquet_id'], 'exist', 'skipOnError' => true, 'targetClass' => BouquetComposition::class, 'targetAttribute' => ['bouquet_id' => 'id'], 'message' => 'Указанный {attribute} не существует'], ]; } - public function attributeLabels() + /** + * {@inheritdoc} + */ + public function attributeLabels(): array { return [ 'id' => 'ID', @@ -72,13 +86,94 @@ class BouquetCompositionProducts extends ActiveRecord ]; } + /** + * Обновляет продукты для указанного букета. + * + * @param int $bouquetId ID букета + * @param array $products Массив продуктов в формате [product_guid => count] + */ + public static function updateProducts(int $bouquetId, array $products): void + { + self::deleteAll(['bouquet_id' => $bouquetId]); + foreach ($products as $guid => $count) { + $product = new self([ + 'bouquet_id' => $bouquetId, + 'product_guid' => $guid, + 'count' => $count, + ]); + $product->save(); + } + } + + /** + * Возвращает список доступных продуктов для выбора. + * + * @param bool $isNewRecord Является ли букет новым + * @param int|null $bouquetId ID букета (если не новый) + * @return array Массив доступных продуктов в формате [id => name] + */ + public static function getAvailableItems(bool $isNewRecord, ?int $bouquetId = null): array + { + $query = Products1c::find() + ->where(['view' => Products1c::IS_VISIBLE, 'tip' => Products1c::TYPE_PRODUCTS]); + + if (!$isNewRecord && $bouquetId) { + $selectedIds = self::find() + ->select('product_guid') + ->where(['bouquet_id' => $bouquetId]) + ->column(); + $query->andWhere(['not in', 'id', $selectedIds]); + } + + return ArrayHelper::map($query->all(), 'id', 'name'); + } + + /** + * Возвращает список выбранных продуктов для букета. + * + * @param int $bouquetId ID букета + * @return array Массив выбранных продуктов в формате [['id' => ..., 'count' => ..., 'text' => ...], ...] + */ + public static function getSelectedItems(int $bouquetId): array + { + return array_map( + fn($product) => [ + 'id' => $product->product_guid, + 'count' => $product->count, + 'text' => $product->product->name ?? '' + ], + self::find()->where(['bouquet_id' => $bouquetId])->with('product')->all() + ); + } + + /** + * Возвращает все продукты, связанные с букетом. + * + * @param int $bouquetId ID букета + * @return static[] Массив объектов продуктов + */ + public static function getCompositionProducts(int $bouquetId): array + { + return self::find()->where(['bouquet_id' => $bouquetId])->all(); + } + + /** + * Отношение к букету. + * + * @return \yii\db\ActiveQuery + */ public function getBouquet() { return $this->hasOne(BouquetComposition::class, ['id' => 'bouquet_id']); } - public function getProduct() { + /** + * Отношение к продукту. + * + * @return \yii\db\ActiveQuery + */ + public function getProduct() + { return $this->hasOne(Products1c::class, ['id' => 'product_guid']); } - -} +} \ No newline at end of file diff --git a/erp24/records/BouquetForecast.php b/erp24/records/BouquetForecast.php index 8c553ae3..bb4ade6d 100644 --- a/erp24/records/BouquetForecast.php +++ b/erp24/records/BouquetForecast.php @@ -9,7 +9,7 @@ use yii\db\ActiveRecord; use yii\db\Expression; /** - * This is the model class for table "bouquet_forecast". + * This is the model class for table "erp24.bouquet_forecast". * * @property int $id * @property int|null $bouquet_id ИД букета @@ -25,7 +25,6 @@ use yii\db\Expression; */ class BouquetForecast extends ActiveRecord { - public const OFFLINE_STORES = 1; public const ONLINE_STORES = 2; public const MARKETPLACE = 3; @@ -33,12 +32,15 @@ class BouquetForecast extends ActiveRecord /** * {@inheritdoc} */ - public static function tableName() + public static function tableName(): string { return '{{%erp24.bouquet_forecast}}'; } - public function behaviors() + /** + * {@inheritdoc} + */ + public function behaviors(): array { return [ [ @@ -58,19 +60,20 @@ class BouquetForecast extends ActiveRecord /** * {@inheritdoc} */ - public function rules() + public function rules(): array { return [ - [['bouquet_id', 'year', 'month', 'type_sales', 'type_sales_id', 'created_by', 'updated_by'], 'integer'], - [['type_sales_value'], 'number'], + [['bouquet_id', 'year', 'month', 'type_sales', 'type_sales_id', 'created_by', 'updated_by'], 'integer', 'message' => '{attribute} должен быть целым числом'], + [['type_sales_value'], 'number', 'message' => '{attribute} должен быть числом'], [['created_at', 'updated_at'], 'safe'], + [['bouquet_id', 'year', 'month', 'type_sales', 'type_sales_id'], 'required', 'message' => '{attribute} обязателен для заполнения'], ]; } /** * {@inheritdoc} */ - public function attributeLabels() + public function attributeLabels(): array { return [ 'id' => 'ID', @@ -87,16 +90,28 @@ class BouquetForecast extends ActiveRecord ]; } - public static function getStoresList($id, $typeSales, $defaultModel, $defaultCondition, $month = null, $year = null) + /** + * Возвращает список магазинов с данными о продажах или без них. + * + * @param int|null $bouquetId ID букета + * @param int $typeSales Тип продаж (OFFLINE_STORES, ONLINE_STORES, MARKETPLACE) + * @param string $defaultModel Класс модели (CityStore или StoreType) + * @param array $defaultCondition Условия для выборки магазинов + * @param int|null $month Месяц (по умолчанию текущий) + * @param int|null $year Год (по умолчанию текущий) + * @return array Список магазинов с данными о продажах + */ + public static function getStoresList(?int $bouquetId, int $typeSales, string $defaultModel, array $defaultCondition, ?int $month = null, ?int $year = null): array { $joinTable = $defaultModel === CityStore::class ? 'city_store' : 'store_type'; + $month = $month ?? (int)date('m'); + $year = $year ?? (int)date('Y'); - - $list = BouquetForecast::find() - ->andWhere(['bouquet_id' => $id]) + $list = self::find() + ->andWhere(['bouquet_id' => $bouquetId]) ->andWhere(['type_sales' => $typeSales]) - ->andWhere(['month' => $month ?? date('m')]) - ->andWhere([ 'year' => $year ?? date('Y')]) + ->andWhere(['month' => $month]) + ->andWhere(['year' => $year]) ->leftJoin("$joinTable AS df", "df.id = bouquet_forecast.type_sales_id") ->select(["df.name AS name", 'type_sales_value AS value', 'type_sales_id AS id']) ->orderBy('type_sales_id') @@ -119,36 +134,83 @@ class BouquetForecast extends ActiveRecord return $list; } - public static function processSalesData($id, $year, $month, $salesData, $salesType) + /** + * Обрабатывает данные о продажах для букета. + * + * @param int $bouquetId ID букета + * @param array $data Данные формы, содержащие BouquetForecast[type_sales_value] + */ + public static function processSalesData(int $bouquetId, array $data): void + { + if (empty($data['BouquetForecast']['type_sales_value'])) { + return; + } + + $salesData = $data['BouquetForecast']['type_sales_value']; + $year = $data['year']; + $month = $data['month']; + $errors = []; + + $types = [ + 'offline' => self::OFFLINE_STORES, + 'online' => self::ONLINE_STORES, + 'marketplace' => self::MARKETPLACE, + ]; + + foreach ($types as $key => $type) { + if (empty($salesData[$key])) { + continue; + } + + $typeErrors = self::updateSalesData($bouquetId, $year, $month, $salesData[$key], $type); + $errors = array_merge($errors, $typeErrors); + } + + if ($errors) { + Yii::$app->session->setFlash('danger', implode(' ', array_map('json_encode', $errors))); + } + } + + /** + * Обновляет или создает записи о продажах для конкретного типа. + * + * @param int $bouquetId ID букета + * @param int $year Год + * @param int $month Месяц + * @param array $salesData Данные о продажах в формате [type_sales_id => value] + * @param int $salesType Тип продаж + * @return array Массив ошибок (пустой, если ошибок нет) + */ + private static function updateSalesData(int $bouquetId, int $year, int $month, array $salesData, int $salesType): array { $errors = []; - foreach ($salesData as $key => $value) { - $model = BouquetForecast::findOne([ - 'bouquet_id' => $id, + foreach ($salesData as $typeSalesId => $value) { + $model = self::findOne([ + 'bouquet_id' => $bouquetId, 'year' => $year, 'month' => $month, 'type_sales' => $salesType, - 'type_sales_id' => $key + 'type_sales_id' => $typeSalesId, ]); - if (empty($model)) { - $model = new BouquetForecast([ - 'bouquet_id' => $id, + if (!$model) { + $model = new self([ + 'bouquet_id' => $bouquetId, 'year' => $year, 'month' => $month, 'type_sales' => $salesType, - 'type_sales_id' => $key, - 'type_sales_value' => $value + 'type_sales_id' => $typeSalesId, + 'type_sales_value' => $value, ]); - - if (!$model->save()) { - $errors[] = $model->errors; - } } elseif ($model->type_sales_value != $value) { - if (!$model->updateAttributes(['type_sales_value' => $value])) { - $errors[] = $model->errors; - } + $model->type_sales_value = $value; + } else { + continue; // Нет изменений, пропускаем + } + + if (!$model->save()) { + $errors[] = $model->getFirstErrors(); } } diff --git a/erp24/views/bouquet/create.php b/erp24/views/bouquet/create.php index eb08e6e1..b9f3e0a0 100644 --- a/erp24/views/bouquet/create.php +++ b/erp24/views/bouquet/create.php @@ -32,7 +32,6 @@ $this->registerJsFile('/js/bouquet/bouquet.js', ['position' => \yii\web\View::PO 'processUrls' => [], 'availableItems' => $availableItems, 'model' => null, - 'flash' => $flash, 'id' => null ]); ?> diff --git a/erp24/web/js/bouquet/bouquet.js b/erp24/web/js/bouquet/bouquet.js index 0c4817a0..4d74d0ac 100644 --- a/erp24/web/js/bouquet/bouquet.js +++ b/erp24/web/js/bouquet/bouquet.js @@ -55,7 +55,7 @@ $(document).ready(function () { } }); -$(document).on('click', '.calculate-btn, #w2-add, #w2-remove', function () { +$(document).on('click', '.calculate-btn, .btn-add-item, .btn-remove-item', function () { $('.cost-value, .selfcost-value, .markup-value').text(''); }); diff --git a/erp24/widgets/DualList.php b/erp24/widgets/DualList.php index f9fa9350..b9acfef8 100644 --- a/erp24/widgets/DualList.php +++ b/erp24/widgets/DualList.php @@ -179,8 +179,8 @@ CSS;
- - + +
-- 2.39.5