From 8e2fce23ba2843ce9828819c2bb1675083e10535 Mon Sep 17 00:00:00 2001 From: Andriy Skavronskyy Date: Wed, 20 Aug 2025 14:26:09 +0300 Subject: [PATCH 1/3] Import-export .csv file --- Api/ProductManagementInterface.php | 4 +- Block/Quickorder.php | 20 +++ Controller/Index/GetList.php | 94 +++++++++++ Controller/Index/UploadCsv.php | 174 ++++++++++++++++++++ Model/GetListManagement.php | 101 ++++++++++++ Model/ProductManagement.php | 111 ++++++++----- Service/CsvProductParser.php | 115 +++++++++++++ Service/FileUploader.php | 10 +- Service/FilterProducts.php | 244 ++++++++++++++++++++++++++++ Service/GetProductsData.php | 81 +++++++++ view/frontend/templates/index.phtml | 205 +++++++++++++++++++---- view/frontend/web/css/styles.css | 72 +++++--- 12 files changed, 1128 insertions(+), 103 deletions(-) create mode 100644 Controller/Index/GetList.php create mode 100644 Controller/Index/UploadCsv.php create mode 100644 Model/GetListManagement.php create mode 100644 Service/CsvProductParser.php create mode 100644 Service/FilterProducts.php create mode 100644 Service/GetProductsData.php diff --git a/Api/ProductManagementInterface.php b/Api/ProductManagementInterface.php index 13bfca2..144b018 100644 --- a/Api/ProductManagementInterface.php +++ b/Api/ProductManagementInterface.php @@ -22,9 +22,9 @@ interface ProductManagementInterface /** * Get product * - * @param string $sku + * @param mixed $sku * @param string $storeCode * @return mixed */ - public function getProduct(string $sku, string $storeCode); + public function getProduct($sku, string $storeCode); } diff --git a/Block/Quickorder.php b/Block/Quickorder.php index d615177..fae37e8 100644 --- a/Block/Quickorder.php +++ b/Block/Quickorder.php @@ -145,6 +145,16 @@ public function getAddToCartUrl() return $this->getUrl('quickorder/index/addtocart'); } + /** + * Get download list url + * + * @return string + */ + public function getDownloadListUrl() + { + return $this->getUrl('quickorder/index/getlist'); + } + /** * Get upload file url * @@ -155,6 +165,16 @@ public function getUploadFileUrl() return $this->getUrl('quickorder/index/upload'); } + /** + * Get csv uploader url + * + * @return string + */ + public function getCsvUploaderUrl() + { + return $this->getUrl('quickorder/index/uploadcsv'); + } + /** * Get form key * diff --git a/Controller/Index/GetList.php b/Controller/Index/GetList.php new file mode 100644 index 0000000..5f59f1b --- /dev/null +++ b/Controller/Index/GetList.php @@ -0,0 +1,94 @@ +request = $request; + $this->formKeyValidator = $formKeyValidator; + $this->getListManagement = $getListManagement; + $this->fileFactory = $fileFactory; + } + + /** + * @return ResponseInterface|null + */ + public function execute(): ?ResponseInterface + { + if (!$this->formKeyValidator->validate($this->request)) { + return null; + } + + try { + $data = $this->getListManagement->getList($this->request->getParam('jsonData')); + + return $this->fileFactory->create($data['csvFileName'], $data['content'], $data['baseDir']); + } catch (Exception | LocalizedException| NoSuchEntityException $e) { + + return null; + } + } +} diff --git a/Controller/Index/UploadCsv.php b/Controller/Index/UploadCsv.php new file mode 100644 index 0000000..639b9c5 --- /dev/null +++ b/Controller/Index/UploadCsv.php @@ -0,0 +1,174 @@ +request = $request; + $this->resultJsonFactory = $resultJsonFactory; + $this->formKeyValidator = $formKeyValidator; + $this->fileUploader = $fileUploader; + $this->csvProductParser = $csvProductParser; + $this->filesystem = $filesystem; + $this->productManagement = $productManagement; + $this->filterProducts = $filterProducts; + } + + /** + * Add to cart + * + * @return ResultInterface + */ + public function execute(): ResultInterface + { + $resultJson = $this->resultJsonFactory->create(); + if (!$this->formKeyValidator->validate($this->request)) { + return $resultJson->setData( + [ + 'success' => false, + 'message' => __('Invalid Form Key. Please refresh the page.') + ] + ); + } + + try { + + $file = $this->request->getFiles('csv'); + if (!$file || empty($file['tmp_name'])) { + return $resultJson->setData( + [ + 'success' => false, + 'message' => __('No file uploaded.') + ] + ); + } + + // basic MIME/extension sanity checks + $allowed = ['text/csv', 'text/plain', 'application/vnd.ms-excel']; + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + if (!in_array($file['type'], $allowed) || !in_array(strtolower($ext), ['csv'])) { + + return $resultJson->setData( + [ + 'success' => false, + 'message' => __('Please upload a .csv file.') + ] + ); + + } + + // make a unique file name + $filename = preg_replace('/[^A-Za-z0-9_\.\-]/', '_', $file['name']); + + $filePath = $this->fileUploader->execute($filename, 'csv', DirectoryList::VAR_DIR); + $varDir = $this->filesystem->getDirectoryRead(DirectoryList::VAR_DIR); + $filePath = $varDir->getAbsolutePath($filePath); + $csvFileContentArray = $this->csvProductParser->execute($filePath); + + $products = $this->productManagement->getProduct($csvFileContentArray, $this->request->getParam('storeCode')); + + $filteredProducts = $this->filterProducts->execute($products, $csvFileContentArray); + + } catch (Exception $e) { + return $resultJson->setData( + [ + 'success' => false, + 'message' => $e->getMessage() + ] + ); + } + + return $resultJson->setData([ + 'success' => true, + 'products' => $filteredProducts + ]); + } +} diff --git a/Model/GetListManagement.php b/Model/GetListManagement.php new file mode 100644 index 0000000..ee30fcf --- /dev/null +++ b/Model/GetListManagement.php @@ -0,0 +1,101 @@ +json = $json; + $this->filesystem = $filesystem; + $this->getProductsData = $getProductsData; + } + + /** + * Create a list of products + * + * @param string $params + * @return array + * @throws FileSystemException + */ + public function getList(string $params) : array + { + $params = $this->json->unserialize($params); + $directory = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_DIR); + + $filepath = 'export/quick_order_product_list.csv'; + $directory->create('export'); + $stream = $directory->openFile($filepath, 'w+'); + $stream->lock(); + + $productsData = $this->getProductsData->execute($params); + + $header = ['sku','qty']; + $i = 0; + while ($i < $productsData['total']) { + $header[] = 'option_' . ($i + 1); + $i++; + } + + $stream->writeCsv($header); + foreach ($productsData['productData'] as $productsDataRow) { + $stream->writeCsv($productsDataRow); + } + + $stream->unlock(); + $stream->close(); + + $content = []; + $content['type'] = 'filename'; + $content['value'] = $filepath; + $content['rm'] = '1'; + + $csvFileName = 'quick_order_product_list_' . date('m_d_Y_H_i') . '.csv'; + + return ['csvFileName' => $csvFileName, 'content' => $content, 'baseDir' => DirectoryList::VAR_DIR]; + } +} diff --git a/Model/ProductManagement.php b/Model/ProductManagement.php index c0ea914..ec55834 100644 --- a/Model/ProductManagement.php +++ b/Model/ProductManagement.php @@ -163,12 +163,16 @@ public function __construct( } /** - * @param string $sku + * @param $sku * @param string $storeCode * @return array */ - public function getProduct(string $sku, string $storeCode): array + public function getProduct($sku, string $storeCode): array { + $skus = is_array($sku) + ? array_column($sku, 'sku') + : [$sku]; + $products = []; $this->currencyCode = $this->storeCurrencyService->execute($storeCode); @@ -184,9 +188,9 @@ public function getProduct(string $sku, string $storeCode): array ); $collection = $this->productCollectionFactory->create(); - $product = $collection->addAttributeToSelect('*') + $productItems = $collection->addAttributeToSelect('*') ->addStoreFilter($this->storeManager->getStore()->getId()) - ->addFieldToFilter('sku', $sku) + ->addFieldToFilter('sku', ['in' => $skus]) ->addAttributeToFilter('status', Status::STATUS_ENABLED) ->addStoreFilter($this->storeIdService->execute($storeCode)) ->setVisibility([ @@ -196,40 +200,43 @@ public function getProduct(string $sku, string $storeCode): array Visibility::VISIBILITY_BOTH ] ]) - ->getLastItem(); - - $product->getTierPrices(); - $data = $product->getData(); - $data['currency_code'] = $this->currencyCode; - $data['currency_symbol'] = $this->currencySymbolService->execute($this->currencyCode); - $data['product_url'] = $product->getProductUrl(); - $data['thumbnail'] = $this->imageFactory->create($product, 'cart_page_product_thumbnail', [])->getImageUrl(); - - if (!empty($data['price'])) { - $converted = $this->currencyConverter->execute($data['price'], $this->currencyCode); - $data['price'] = $data['default_price'] = $converted; - } - - $data['stock'] = $this->stockRepository->get($data['entity_id'])->getQty(); - $data['qty'] = 1; - - switch ($data['type_id']) { - case Configurable::TYPE_CODE: - $data = $this->processConfigurable($product, $data); - break; - case Type::TYPE_BUNDLE: - $data = $this->processBundle($product, $data); - break; - case 'grouped': - $data = $this->processGrouped($product, $data); - break; + ->getItems(); + + foreach ($productItems as $product) { + $product->getTierPrices(); + + $data = $product->getData(); + $data['currency_code'] = $this->currencyCode; + $data['currency_symbol'] = $this->currencySymbolService->execute($this->currencyCode); + $data['product_url'] = $product->getProductUrl(); + $data['thumbnail'] = $this->imageFactory->create($product, 'cart_page_product_thumbnail', [])->getImageUrl(); + + if (!empty($data['price'])) { + $converted = $this->currencyConverter->execute($data['price'], $this->currencyCode); + $data['price'] = $data['default_price'] = $converted; + } + + $data['stock'] = $this->stockRepository->get($data['entity_id'])->getQty(); + $data['qty'] = 1; + + switch ($data['type_id']) { + case Configurable::TYPE_CODE: + $data = $this->processConfigurable($product, $data); + break; + case Type::TYPE_BUNDLE: + $data = $this->processBundle($product, $data); + break; + case 'grouped': + $data = $this->processGrouped($product, $data); + break; + } + + $data['custom_options'] = $this->processCustomOptions($product); + $data['active_custom_options'] = []; + + $products[] = $data; } - $data['custom_options'] = $this->processCustomOptions($product); - $data['active_custom_options'] = []; - - $products[] = $data; - } catch (NoSuchEntityException $e) { $this->logger->error(sprintf('Error fetching product: %s', $e->getMessage()), $e->getTrace()); } @@ -246,13 +253,31 @@ public function getProduct(string $sku, string $storeCode): array */ private function processConfigurable(Product $product, array $data): array { + $associatedProducts = []; $attrs = $product->getTypeInstance()->getConfigurableAttributesAsArray($product); $activeAttrs = $this->getActiveAttributes($attrs); $associated = $this->getAssociatedProducts($product); + foreach ($associated as $associatedProduct) { + $options = []; + foreach ($attrs as $attr) { + $optionCodeValue = $associatedProduct[$attr['attribute_code']] ?? null; + + $valueMap = array_column($attr['values'], 'label', 'value_index'); + + $options[] = [ + 'option_name' => $attr['label'], + 'option_value' => $valueMap[$optionCodeValue] ?? null, + ]; + } + + $associatedProduct['options'] = $options; + $associatedProducts[] = $associatedProduct; + } + $data['attributes'] = array_values($attrs); - $data['active_product'] = $this->matchVariant($associated, $activeAttrs); - $data['used_products'] = $associated; + $data['active_product'] = $this->matchVariant($associatedProducts, $activeAttrs); + $data['used_products'] = $associatedProducts; return $data; } @@ -273,7 +298,7 @@ private function processBundle(Product $product, array $data): array 'option_id' => $opt->getOptionId(), 'option_type' => $opt->getType(), 'position' => $opt->getPosition(), - 'require' => $opt->getRequired(), + 'required' => $opt->getRequired(), ]; } @@ -293,7 +318,7 @@ private function processBundle(Product $product, array $data): array 'option_id' => $sel->getOptionId(), 'product_name' => $sel->getName(), 'qty' => (int)$sel->getSelectionQty(), - 'require' => $optionInfo[$sel->getOptionId()]['require'], + 'required' => $optionInfo[$sel->getOptionId()]['required'], 'selection_id' => $sel->getSelectionId(), 'title' => $optionTitles[$sel->getOptionId()], 'type' => $optionInfo[$sel->getOptionId()]['option_type'] @@ -336,7 +361,7 @@ private function processGrouped(Product $product, array $data): array $childData['thumbnail'] = $this->imageFactory->create($child, 'product_base_image', [])->getImageUrl(); $groupedProducts[] = $childData; - $activeSelections[] = ['id' => $childData['entity_id'], 'qty' => $childData['qty'] ?? 1]; + $activeSelections[] = ['id' => $childData['entity_id'], 'qty' => $childData['qty'] ?? 1, 'name' => $childData['name']]; } usort($groupedProducts, fn ($a, $b) => ($a['position'] ?? 0) <=> ($b['position'] ?? 0)); @@ -401,6 +426,8 @@ private function buildSelectionStructure(array $dataSet): array 'value_id' => $item['selection_id'] ?? null, 'value' => (bool)($item['is_default'] ?? false), 'change_qty' => $item['can_change_qty'] ?? false, + 'product_name' => $item['product_name'] ?? false, + 'title' => $item['title'] ?? false, 'qty' => $item['qty'] ?? 0 ], $dataSet[$key] ?? []) ]; @@ -525,4 +552,4 @@ private function resolveOptionPrice(Value|Option $opt) } return $price; } -} \ No newline at end of file +} diff --git a/Service/CsvProductParser.php b/Service/CsvProductParser.php new file mode 100644 index 0000000..91f2762 --- /dev/null +++ b/Service/CsvProductParser.php @@ -0,0 +1,115 @@ +logger = $logger; + } + + /** + * Parse CSV file into structured array + * + * @param string $filePath + * @return array + * @throws \RuntimeException + */ + public function execute(string $filePath): array + { + if (!file_exists($filePath)) { + throw new \RuntimeException("File not found: {$filePath}"); + } + + $handle = fopen($filePath, 'r'); + if ($handle === false) { + throw new \RuntimeException("Cannot open file: {$filePath}"); + } + + $header = fgetcsv($handle); + if (!$header) { + throw new \RuntimeException("CSV file is empty or invalid: {$filePath}"); + } + + $result = []; + $line = 1; + + while (($row = fgetcsv($handle)) !== false) { + $line++; + + if (count($row) < count($header)) { + $this->logger->warning("Line {$line}: less columns than header. Padding with nulls.", [ + 'row' => $row + ]); + $row = array_pad($row, count($header), null); + } elseif (count($row) > count($header)) { + $this->logger->warning("Line {$line}: more columns than header. Extra values will be dropped.", [ + 'row' => $row + ]); + $row = array_slice($row, 0, count($header)); + } + + $item = array_combine($header, $row); + + if (!$item || empty($item['sku'])) { + continue; // пропускаємо пусті строки + } + + $options = []; + foreach ($item as $key => $value) { + if (str_starts_with($key, 'option_') && !empty($value)) { + $parts = explode(':', $value); + + if (count($parts) === 3) { + [$label, $val, $qty] = $parts; + $options[] = [ + 'label' => trim($label), + 'value' => trim($val), + 'qty' => (int)$qty, + ]; + } elseif (count($parts) === 2) { + [$label, $val] = $parts; + $options[] = [ + 'label' => trim($label), + 'value' => trim($val), + ]; + } + } + } + + $result[] = [ + 'sku' => trim($item['sku']), + 'qty' => (int)$item['qty'], + 'options' => $options + ]; + } + + fclose($handle); + + return $result; + } +} diff --git a/Service/FileUploader.php b/Service/FileUploader.php index 6dc07f0..48254b4 100644 --- a/Service/FileUploader.php +++ b/Service/FileUploader.php @@ -13,10 +13,10 @@ namespace BroSolutions\QuickOrder\Service; +use Exception; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\Framework\File\UploaderFactory; use Magento\Framework\Filesystem; -use Exception; use Magento\Framework\Filesystem\Io\File; /** @@ -64,17 +64,19 @@ public function __construct( * File upload * * @param string $fileName + * @param string $fileInputName + * @param string $directory * @return string * @throws Exception */ - public function execute(string $fileName): string + public function execute(string $fileName, string $fileInputName = 'file', string $directory = DirectoryList::MEDIA): string { $folderStructure = $this->generateFolderStructure($fileName); - $uploader = $this->uploaderFactory->create(['fileId' => 'file']); + $uploader = $this->uploaderFactory->create(['fileId' => $fileInputName]); $uploader->setAllowCreateFolders(true); - $directory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); + $directory = $this->filesystem->getDirectoryRead($directory); $uploadDir = $directory->getAbsolutePath(self::DIRECTORY_TO_DOWNLOAD . '/' . $folderStructure); if (!$this->fileIo->fileExists($uploadDir)) { diff --git a/Service/FilterProducts.php b/Service/FilterProducts.php new file mode 100644 index 0000000..40dd4c0 --- /dev/null +++ b/Service/FilterProducts.php @@ -0,0 +1,244 @@ +processConfigurable($product, $options); + break; + case Type::TYPE_BUNDLE: + $data[] = $this->processBundle($product, $options); + break; + case 'grouped': + $data[] = $this->processGrouped($product, $options); + break; + case 'simple': + $data[] = $this->processSimple($product, $options); + break; + } + } + + return $data; + } + + /** + * @param array $product + * @param array $options + * @return array + */ + private function processConfigurable(array $product, array $options): array + { + if (empty($option = $this->getOptionFromProduct($product, $options))) { + return $product; + } + + foreach ($product['used_products'] as $usedProduct) { + if ($this->hasExactOptions($usedProduct, $option[0]['options'])) { + $product['active_product'] = $usedProduct; + $product['qty'] = $option[0]['qty']; + break; + } + } + + return $product; + } + + /** + * @param array $product + * @param array $options + * @return array + */ + private function processBundle(array $product, array $options) + { + if (empty($option = $this->getOptionFromProduct($product, $options))) { + return $product; + } + if (empty($options[0]['qty'])) { + return $product; + } + + $product['qty'] = $options[0]['qty']; + + $data = []; + foreach ($product['active_selections'] as $activeSelection) { + $selectionValueArr = []; + foreach ($activeSelection['selection_value'] as $selectionValue) { + $selectionValue['value'] = false; + $qty = $this->getQtyByLabelValue($option[0], $selectionValue['title'], $selectionValue['product_name']); + if ($qty) { + $selectionValue['value'] = true; + $selectionValue['qty'] = $qty; + } + $selectionValueArr[] = $selectionValue; + } + $activeSelection['selection_value'] = $selectionValueArr; + $data[] = $activeSelection; + } + $product['active_selections'] = $data; + + return $product; + } + + /** + * @param array $product + * @param array $options + * @return array + */ + private function processGrouped(array $product, array $options) + { + if (empty($option = $this->getOptionFromProduct($product, $options))) { + return $product; + } + + $data = []; + foreach ($product['active_selections'] as $activeSelection) { + $qty = $this->getQtyByActiveSelection($option[0], $activeSelection['name']); + if ($qty) { + $activeSelection['qty'] = $qty; + } + $data[] = $activeSelection; + + } + $product['active_selections'] = $data; + + return $product; + } + + /** + * @param array $product + * @param array $options + * @return array + */ + private function processSimple(array $product, array $options): array + { + if (empty($options[0]['qty'])) { + return $product; + } + + $product['qty'] = $options[0]['qty']; + + return $product; + } + + + /** + * @param array $product + * @param array $expectedOptions + * @return bool + */ + public function hasExactOptions(array $product, array $expectedOptions): bool + { + if (!isset($product['options']) || !is_array($product['options'])) { + return false; + } + + $actualOptions = []; + foreach ($product['options'] as $opt) { + $actualOptions[$opt['option_name']] = $opt['option_value']; + } + + $normalizedExpected = []; + foreach ($expectedOptions as $opt) { + $normalizedExpected[$opt['label']] = $opt['value']; + } + + if (count($actualOptions) !== count($normalizedExpected)) { + return false; + } + + foreach ($normalizedExpected as $name => $value) { + if (!isset($actualOptions[$name]) || $actualOptions[$name] !== $value) { + return false; + } + } + + return true; + } + + /** + * @param array $product + * @param string $label + * @param string $value + * @return int + */ + public function getQtyByLabelValue(array $product, string $label, string $value): int + { + $totalQty = 0; + foreach ($product['options'] as $opt) { + if (isset($opt['label'], $opt['value'], $opt['qty']) && $opt['label'] === $label && $opt['value'] === $value) { + return (int)$opt['qty']; + } + } + + return $totalQty; + } + + /** + * @param array $product + * @param string $name + * @return int + */ + public function getQtyByActiveSelection(array $product, string $name): int + { + $totalQty = 0; + foreach ($product['options'] as $opt) { + if (isset($opt['label']) && $opt['label'] === $name) { + return (int)$opt['value']; + } + } + + return $totalQty; + } + + /** + * @param $product + * @param $options + * @return array|null + */ + private function getOptionFromProduct($product, $options): ?array + { + $skuToFind = $product['sku']; + + $option = array_values(array_filter($options, function ($item) use ($skuToFind) { + return $item['sku'] === $skuToFind; + })); + + if (empty($option[0]['options'])) { + return null; + } + + return $option; + } +} diff --git a/Service/GetProductsData.php b/Service/GetProductsData.php new file mode 100644 index 0000000..a5943c5 --- /dev/null +++ b/Service/GetProductsData.php @@ -0,0 +1,81 @@ + $optionsTotal) { + $optionsTotal = count($options); + } + } + + $data['productData'] = $productData; + $data['total'] = $optionsTotal; + + return $data; + } +} diff --git a/view/frontend/templates/index.phtml b/view/frontend/templates/index.phtml index abec327..f890037 100644 --- a/view/frontend/templates/index.phtml +++ b/view/frontend/templates/index.phtml @@ -21,14 +21,14 @@ use Magento\Framework\Escaper; **/ ?> -

escapeHtml(__('Quick Order'))?>

+

escapeHtml(__('Quick Order')) ?>

- - + +
-

escapeHtml(__('Magento Product Info'))?>

+

escapeHtml(__('Magento Product Info')) ?>

@@ -36,19 +36,26 @@ use Magento\Framework\Escaper;
-