diff --git a/Api/Export/ExporterInterface.php b/Api/Export/ExporterInterface.php deleted file mode 100644 index 022be05..0000000 --- a/Api/Export/ExporterInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -appState = $appState; - $this->exporter = $exporter; - $this->preExportValidators = $preExportValidators; - parent::__construct($name); - } - - protected function configure() - { - $this->setName('fredhopper:indexer:full_export') - ->setDescription('Export full set of products to Fredhopper'); - - $desc = 'If true, zip file will be generated, but no upload to FH will be performed'; - $this->addOption('dry-run', null, null, $desc); - } - - /** - * @param InputInterface $input - * @param OutputInterface $output - * @return int - * @throws LocalizedException - * @throws FileSystemException - */ - public function execute(InputInterface $input, OutputInterface $output): int - { - try { - $this->appState->getAreaCode(); - } catch (LocalizedException $e) { - try { - $this->appState->setAreaCode(Area::AREA_ADMINHTML); - } catch (LocalizedException $e) { - // this shouldn't happen, but don't attempt to continue if it does - $output->writeln('Could not set area code - aborting'); - return Cli::RETURN_FAILURE; - } - } - - $this->exporter->setDryRun($input->getOption('dry-run')); - - $validationErrors = []; - foreach ($this->preExportValidators as $preExportValidator) { - try { - $preExportValidator->validateState(); - } catch (ValidationException $e) { - $validationErrors[] = $e->getMessage(); - } - } - if (!empty($validationErrors)) { - $output->writeln('Export failed validation checks:'); - foreach ($validationErrors as $errorMessage) { - $output->writeln($errorMessage); - } - return Cli::RETURN_FAILURE; - } - $this->exporter->export(); - return Cli::RETURN_SUCCESS; - } -} diff --git a/Console/Command/SpecificProductExport.php b/Console/Command/SpecificProductExport.php deleted file mode 100644 index f3d84a0..0000000 --- a/Console/Command/SpecificProductExport.php +++ /dev/null @@ -1,94 +0,0 @@ -state = $state; - $this->exporter = $exporter; - $this->immediateProducts = $immediateProducts; - parent::__construct($name); - } - - /** - * @inheritDoc - */ - protected function configure() - { - $this->setName('fredhopper:indexer:immediate_export') - ->setDescription('Run the Fredhopper export for specific products'); - - $this->setHelp(<<addOption('dry-run', null, null, $desc); - - $desc = 'Product SKU(s), e.g. ABC123.456'; - $this->addArgument('sku', InputArgument::REQUIRED | InputArgument::IS_ARRAY, $desc); - } - - /** - * @inheritDoc - */ - public function execute(InputInterface $input, OutputInterface $output) - { - $this->exporter->setDryRun($input->getOption('dry-run')); - - $skus = $input->getArgument('sku'); - - $this->immediateProducts->setSkus($skus); - - try { - $this->state->setAreaCode(Area::AREA_ADMINHTML); - } catch (\Exception $ex) { - // Just keep swimming - ; - } - $this->exporter->export(); - - return Cli::RETURN_SUCCESS; - } -} diff --git a/Console/Command/TouchProduct.php b/Console/Command/TouchProduct.php deleted file mode 100644 index 483b829..0000000 --- a/Console/Command/TouchProduct.php +++ /dev/null @@ -1,77 +0,0 @@ -resourceConnection = $resourceConnection; - parent::__construct($name); - } - - protected function configure() - { - $this->setName('fredhopper:indexer:touch_product') - ->setDescription('Trigger reindex of one or more products'); - - $this->setHelp(<<addArgument('sku', InputArgument::REQUIRED | InputArgument::IS_ARRAY, $desc); - } - - /** - * @inheritDoc - */ - public function execute(InputInterface $input, OutputInterface $output): int - { - $skus = $input->getArgument('sku'); - if (count($skus) < 1) { - throw new \InvalidArgumentException('Must provide SKUs'); - } - - $conn = $this->resourceConnection->getConnection(); - $productFetch = $conn->select() - ->from($conn->getTableName('catalog_product_entity')) - ->reset(\Zend_Db_Select::COLUMNS) - ->columns(['entity_id']) - ->where("sku IN (?)", $skus); - $insertData = []; - foreach ($conn->query($productFetch) as $row) { - $insertData[] = ['entity_id' => $row['entity_id']]; - } - - if (count($insertData) == 0) { - $output->writeln("No matching products"); - return Cli::RETURN_FAILURE; - } - - $table = $conn->getTableName('catalogsearch_fulltext_cl'); - try { - $conn->insertMultiple($table, $insertData); - $output->writeln("Updated " . count($insertData) . " product(s)"); - return Cli::RETURN_SUCCESS; - } catch (\Exception $ex) { - $output->writeln("Failed to update changelog"); - return Cli::RETURN_FAILURE; - } - } -} diff --git a/Console/Command/ValidateProductExport.php b/Console/Command/ValidateProductExport.php deleted file mode 100644 index d856b79..0000000 --- a/Console/Command/ValidateProductExport.php +++ /dev/null @@ -1,263 +0,0 @@ -jsonSerializer = $jsonSerializer; - $this->filesystemDriver = $filesystemDriver; - parent::__construct($name); - } - - protected function configure() - { - $this->setName('fredhopper:indexer:validate_export') - ->setDescription('Validate export of one or more products'); - - $this->setHelp( - "Searches the history of Fredhopper exports for specified products, and displays " . - "the most recently exported data for each export type (incremental and full), for " . - "manual validation by the operator" - ); - - $desc = 'SKU(s), e.g. ABC123'; - $this->addArgument('sku', InputArgument::REQUIRED | InputArgument::IS_ARRAY, $desc); - } - - /** - * Get the base directory which contains the Fredhopper exports - * @return string - */ - public function getBaseDir(): string - { - // TODO: add config value that determines where the exports are stored - return '/tmp/'; - } - - /** - * Get two lists of directory paths of FH exports: incremental, and full - * @return array[] - * @throws FileSystemException - */ - private function getDirs(): array - { - $files = Glob::glob($this->getBaseDir() . 'fh_export_*', GLOB_NOSORT); - $incremental = $full = []; - foreach ($files as $file) { - $time = (int)($this->filesystemDriver->stat($file)['mtime'] ?? 0); - if (strpos($file, 'incremental') !== false) { - $incremental[$time] = $file; - } else { - $full[$time] = $file; - } - - } - krsort($incremental); - krsort($full); - return [$incremental, $full]; - } - - /** - * Extract the product definitions for a set of SKUs from a single product export JSON file - * - * @param string $filePath - * @param array $skus - * @param OutputInterface $output - * @return array [sku => [attribute_code => value]] - */ - private function extractSkus(string $filePath, array $skus, OutputInterface $output): array - { - try { - $data = $this->jsonSerializer->unserialize($this->filesystemDriver->fileGetContents($filePath)); - } catch (\Exception $ex) { // phpcs:ignore - // No drama, the check for $data['products'] will handle this - } - - if (empty($data['products'])) { - $output->writeln("Unable to read JSON from $filePath"); - return []; - } - $skuProducts = []; - foreach ($data['products'] as $idx => $product) { - $productId = $product['product_id'] ?? "unknown; (index $idx)"; - if (empty($product['attributes'])) { - $output->writeln("Missing attributes for product $productId"); - continue; - } - $formattedAttrs = []; - $sku = null; - foreach ($product['attributes'] as $attr) { - if (empty($attr['attribute_id']) || empty($attr['values'])) { - continue; - } - $attrId = $attr['attribute_id']; - $values = []; - foreach ($attr['values'] as $val) { - if (!isset($val['value'])) { - continue; - } - $values[] = $val['value']; - } - - if ($attrId == 'sku') { - $skuMatch = false; - foreach ($values as $val) { - if (isset($skus[$val])) { - $skuMatch = true; - $sku = $val; - break; - } - } - if (!$skuMatch) { - continue 2; - } - } - - $formattedAttrs[$attrId] = implode(' | ', $values); - } - if (!$sku) { - continue; - } - $skuProducts[$sku] = $formattedAttrs; - - if (count($skuProducts) == count($skus)) { - break; - } - } - return $skuProducts; - } - - private function formatTime(int $timeDiff): string - { - $measures = [ - 'd' => 86400, // 24hr - 'h' => 3600, - 'm' => 60, - 's' => 1, - ]; - $formatted = ''; - foreach ($measures as $unit => $length) { - if ($timeDiff > $length) { - $num = (int)floor($timeDiff / $length); - $timeDiff -= $num * $length; - $formatted .= $num . $unit . ' '; - } - } - return rtrim($formatted); - } - - /** - * @throws FileSystemException - */ - public function execute(InputInterface $input, OutputInterface $output): int - { - $requestedSkus = $input->getArgument('sku'); - $skus = []; - foreach ($requestedSkus as $sku) { - $skus[$sku] = true; - } - - $now = time(); - $groupedDirs = $this->getDirs(); - $processedSkus = []; - foreach ($groupedDirs as $dirs) { - $skusToProcess = array_diff($skus, $processedSkus); - foreach ($dirs as $dir) { - $processedSkus[] = $this->executeSingleDir($dir, $skus, $skusToProcess, $output, $now); - } - } - $processedSkus = array_merge([], ...$processedSkus); - foreach ($processedSkus as $sku) { - unset($skus[$sku]); - } - if (count($skus) == 0) { - $output->writeln("All SKUs found"); - return Cli::RETURN_SUCCESS; - } else { - $output->writeln("SKUs not found: " . implode(', ', array_keys($skus))); - return Cli::RETURN_FAILURE; - } - } - - private function executeSingleDir( - string $dir, - array $skus, - array $skusToProcess, - OutputInterface $output, - int $now - ): array { - - $processedSkus = []; - - $files = Glob::glob($dir . '/products-*.json'); - foreach ($files as $file) { - $products = $this->extractSkus($file, $skus, $output); - if (count($products) == 0) { - continue; - } - $msg = "In file $file"; - $matches = []; - preg_match('/[0-9]+/', $file, $matches); - if (!empty($matches[0])) { - $fileTime = (int)$matches[0]; - $timeDiff = $now - $fileTime; - $msg .= ' (' . $this->formatTime($timeDiff) . ' ago)'; - } - $delimitLine = str_repeat('=', strlen($msg)); - $output->writeln($delimitLine); - $output->writeln($msg); - foreach ($products as $sku => $product) { - $processedSkus[] = $sku; - $output->writeln("=== $sku ==="); - foreach ($product as $attr => $vals) { - // Pretty print attributes that are JSON blobs - $isJson = false; - if (strlen($vals) > 0 && ($vals[0] == '{' || $vals[0] == '[')) { - try { - $decodedVals = $this->jsonSerializer->unserialize($vals); - - // N.B. $this->jsonSerializer has no option for pretty printing - $vals = json_encode($decodedVals, JSON_PRETTY_PRINT); - $isJson = true; - } catch (\Exception $ex) { // phpcs:ignore - // looked like JSON, but wasn't valid JSON, so treat as normal string - } - } - if (!$isJson && strlen($vals) > $this->maxLength) { - $vals = substr($vals, 0, $this->maxLength) . ' ... '; - } - $output->writeln("$attr: $vals"); - } - } - $output->writeln($delimitLine); - // we've processed all the needed skus - if (count($processedSkus) === count($skusToProcess)) { - break; - } - } - return $processedSkus; - } -} diff --git a/Cron/FredhopperExport.php b/Cron/FredhopperExport.php deleted file mode 100644 index 1dbfcb1..0000000 --- a/Cron/FredhopperExport.php +++ /dev/null @@ -1,63 +0,0 @@ -fredhopperExporter = $fredhopperExporter; - $this->sanityConfig = $sanityConfig; - $this->emailHelper = $emailHelper; - $this->preExportValidators = $preExportValidators; - } - - /** - * @throws ValidationException - */ - public function export(): void - { - try { - foreach ($this->preExportValidators as $preExportValidator) { - $preExportValidator->validateState(); - } - } catch (ValidationException $ex) { - $recipients = $this->sanityConfig->getErrorEmailRecipients(); - if (count($recipients) > 0) { - $this->emailHelper->sendErrorEmail($recipients, [$ex->getMessage()]); - } - throw $ex; - } - - $this->fredhopperExporter->export(); - } -} diff --git a/Data/CreatedAtOptionSource.php b/Data/CreatedAtOptionSource.php deleted file mode 100644 index 3d11af5..0000000 --- a/Data/CreatedAtOptionSource.php +++ /dev/null @@ -1,43 +0,0 @@ -dataProvider = $dataProvider; - } - - public function toOptionArray(): array - { - if (!isset($this->options)) { - $options = [ - ['value' => 'created_at', 'label' => __('Created at')], - ]; - $ids = []; - foreach ($this->dataProvider->getSearchableAttributes() as $attr) { - $code = $attr->getAttributeCode(); - // All attributes are duplicated (one entry for id, one for attribute_code) :/ - if (isset($ids[$code]) || $attr->getBackendType() !== 'datetime') { - continue; - } - $options[] = ['value' => $code, 'label' => $attr->getFrontendLabel()]; - $ids[$code] = true; - } - $this->options = $options; - } - return $this->options; - } -} diff --git a/Helper/AgeAttributeConfig.php b/Helper/AgeAttributeConfig.php deleted file mode 100644 index 0258451..0000000 --- a/Helper/AgeAttributeConfig.php +++ /dev/null @@ -1,72 +0,0 @@ -sendNewIndicator)) { - $this->sendNewIndicator = $this->scopeConfig->isSetFlag(self::XML_PATH_SEND_NEW_INDICATOR); - } - return $this->sendNewIndicator; - } - - /** - * @return bool - */ - public function getSendDaysOnline(): bool - { - if (!isset($this->sendDaysOnline)) { - $this->sendDaysOnline = $this->scopeConfig->isSetFlag(self::XML_PATH_SEND_DAYS_ONLINE); - } - return $this->sendDaysOnline; - } - - /** - * @return string - */ - public function getCreatedAtFieldName(): string - { - if (!isset($this->createdAtFieldName)) { - $this->createdAtFieldName = (string)$this->scopeConfig->getValue(self::XML_PATH_CREATED_AT_FIELD); - } - return $this->createdAtFieldName; - } - - /** - * @return bool - */ - public function getUseSiteVariant(): bool - { - if (!isset($this->useSiteVariantAge)) { - $this->useSiteVariantAge = parent::getUseSiteVariant() && - $this->scopeConfig->isSetFlag(self::XML_PATH_USE_SITE_VARIANT); - } - return $this->useSiteVariantAge; - } - - /** - * @return string[] - */ - public function getAllSiteVariantSuffixes(): array - { - return $this->getUseSiteVariant() ? parent::getAllSiteVariantSuffixes() : ['']; - } -} diff --git a/Helper/Email.php b/Helper/Email.php deleted file mode 100644 index a9ab081..0000000 --- a/Helper/Email.php +++ /dev/null @@ -1,144 +0,0 @@ -transportBuilder = $transportBuilder; - $this->inlineTranslation = $inlineTranslation; - $this->escaper = $escaper; - $this->indexerInfo = $indexerInfo; - } - - /** - * @param array $errors - * @return string - */ - public function getErrorHtml(array $errors): string - { - if (empty($errors)) { - return ''; - } - $output = "\n"; - return $output; - } - - /** - * @return string - */ - public function getIndexInfoTables(): string - { - $html = ''; - - // Collate `bin/magento indexer:status` data - $data = $this->indexerInfo->getIndexState(); - if (!empty($data)) { - $html .= "\n"; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= "\n"; - foreach ($data as $indexer) { - $scheduleStatus = "{$indexer['schedule_status']} ({$indexer['schedule_backlog']} in backlog)"; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= "\n"; - } - $html .= "
IndexerStatusSchedule StatusSchedule Updated
' . $this->escaper->escapeHtml($indexer['id']) . '' . $this->escaper->escapeHtml($indexer['status']) . '' . $this->escaper->escapeHtml($scheduleStatus) . '' . $this->escaper->escapeHtml($indexer['schedule_updated']) . '
\n"; - } - - $data = $this->indexerInfo->getFredhopperIndexState(); - if (!empty($data)) { - $html .= "
\n"; - $html .= "\n"; - $html .= ''; - $row = reset($data); - foreach ($row as $heading => $junk) { - $html .= ''; - } - $html .= "\n"; - foreach ($data as $row) { - $html .= ''; - foreach ($row as $value) { - $html .= ''; - } - $html .= "\n"; - } - $html .= "
' . $this->escaper->escapeHtml(ucfirst($heading)) . '
' . $this->escaper->escapeHtml($value) . '
\n"; - } - - return $html; - } - - /** - * @param array $to - * @param array $errors - * @return bool - */ - public function sendErrorEmail(array $to, array $errors): bool - { - $this->inlineTranslation->suspend(); - try { - $templateVars = [ - 'errors' => $this->getErrorHtml($errors), - 'indexer_info' => $this->getIndexInfoTables(), - ]; - - $transport = $this->transportBuilder - ->setTemplateIdentifier(SanityCheckConfig::EMAIL_TEMPLATE) - ->setTemplateOptions( - [ - 'area' => Area::AREA_ADMINHTML, - 'store' => Store::DEFAULT_STORE_ID, - ] - ) - ->setTemplateVars($templateVars) - ->setFromByScope('general') - ->addTo($to) - ->getTransport(); - - $transport->sendMessage(); - $this->inlineTranslation->resume(); - - return true; - - } catch (\Exception $e) { - $this->inlineTranslation->resume(); - return false; - } - } -} diff --git a/Helper/GeneralConfig.php b/Helper/GeneralConfig.php deleted file mode 100644 index b1c2f48..0000000 --- a/Helper/GeneralConfig.php +++ /dev/null @@ -1,236 +0,0 @@ -localeResolver = $localeResolver; - $this->storeManager = $storeManager; - } - - /** - * @return string - */ - public function getUsername(): string - { - if (!isset($this->username)) { - $this->username = (string)$this->scopeConfig->getValue(self::XML_PATH_USERNAME); - } - return $this->username; - } - - /** - * @return string - */ - public function getPassword(): string - { - if (!isset($this->password)) { - $this->password = (string)$this->scopeConfig->getValue(self::XML_PATH_PASSWORD); - } - return $this->password; - } - - /** - * @return string - */ - public function getEnvironmentName(): string - { - if (!isset($this->environmentName)) { - $this->environmentName = (string)$this->scopeConfig->getValue(self::XML_PATH_ENVIRONMENT); - } - return $this->environmentName; - } - - /** - * @return string - */ - public function getEndpointName(): string - { - if (!isset($this->endpointName)) { - $this->endpointName = (string)$this->scopeConfig->getValue(self::XML_PATH_ENDPOINT); - } - return $this->endpointName; - } - - /** - * @return string - */ - public function getProductPrefix(): string - { - if (!isset($this->productPrefix)) { - $this->productPrefix = (string)$this->scopeConfig->getValue(self::XML_PATH_PRODUCT_PREFIX); - } - return $this->productPrefix; - } - - /** - * @return string - */ - public function getVariantPrefix(): string - { - if (!isset($this->variantPrefix)) { - $this->variantPrefix = (string)$this->scopeConfig->getValue(self::XML_PATH_VARIANT_PREFIX); - } - return $this->variantPrefix; - } - - /** - * @return bool - */ - public function getUseSiteVariant(): bool - { - if (!isset($this->useSiteVariant)) { - $this->useSiteVariant = $this->scopeConfig->isSetFlag(self::XML_PATH_USE_SITE_VARIANT); - } - return $this->useSiteVariant; - } - - /** - * @return int - */ - public function getDefaultStore(): int - { - if (!isset($this->defaultStore)) { - $this->defaultStore = (int)$this->scopeConfig->getValue(self::XML_PATH_DEFAULT_STORE); - } - return $this->defaultStore; - } - - /** - * @return array - */ - public function getExcludedStores(): array - { - if (!isset($this->excludedStores)) { - $configValue = (string)$this->scopeConfig->getValue((self::XML_PATH_EXCLUDED_STORES)); - $this->excludedStores = explode(',', $configValue); - } - return $this->excludedStores; - } - - /** - * @return string - */ - public function getDefaultLocale(): string - { - if (!isset($this->defaultLocale)) { - $this->localeResolver->emulate($this->getDefaultStore()); - $this->defaultLocale = $this->localeResolver->getLocale(); - $this->localeResolver->revert(); - } - return $this->defaultLocale; - } - - /** - * @param int|null $storeId - * @return string|null - */ - public function getSiteVariant(?int $storeId = null): ?string - { - $storeKey = $storeId ?? 'default'; - if (!isset($this->siteVariants[$storeKey])) { - $this->siteVariants[$storeKey] = $this->scopeConfig->getValue( - self::XML_PATH_SITE_VARIANT, - ScopeInterface::SCOPE_STORE, - $storeId - ); - } - return $this->siteVariants[$storeKey]; - } - - /** - * @return string[] - */ - public function getAllSiteVariantSuffixes(): array - { - if (!isset($this->allSiteVariantSuffixes)) { - if (!$this->getUseSiteVariant()) { - $this->allSiteVariantSuffixes = ['']; // single empty string, rather than empty array - } else { - foreach ($this->storeManager->getStores() as $store) { - if (in_array($store->getId(), $this->getExcludedStores())) { - continue; - } - $siteVariant = $this->getSiteVariant((int)$store->getId()); - $this->allSiteVariantSuffixes[$siteVariant] = '_' . $siteVariant; - } - } - } - return $this->allSiteVariantSuffixes; - } - - /** - * @return int - */ - public function getRootCategoryId(): int - { - if (!isset($this->rootCategoryId)) { - $this->rootCategoryId = (int) $this->scopeConfig->getValue(self::XML_PATH_ROOT_CATEGORY); - if ($this->rootCategoryId <= 0) { - $this->rootCategoryId = Category::TREE_ROOT_ID; - } - } - return $this->rootCategoryId; - } - - /** - * @return bool - */ - public function getDebugLogging(): bool - { - if (!isset($this->debugLogging)) { - $this->debugLogging = $this->scopeConfig->isSetFlag(self::XML_PATH_DEBUG_LOGGING); - } - return $this->debugLogging; - } -} diff --git a/Helper/ImageAttributeConfig.php b/Helper/ImageAttributeConfig.php deleted file mode 100644 index 7f27b8e..0000000 --- a/Helper/ImageAttributeConfig.php +++ /dev/null @@ -1,33 +0,0 @@ -useSiteVariantImages)) { - $this->useSiteVariantImages = parent::getUseSiteVariant() && - $this->scopeConfig->isSetFlag(self::XML_PATH_USE_SITE_VARIANT); - } - return $this->useSiteVariantImages; - } - - /** - * @return string[] - */ - public function getAllSiteVariantSuffixes(): array - { - return $this->getUseSiteVariant() ? parent::getAllSiteVariantSuffixes() : ['']; - } -} diff --git a/Helper/PricingAttributeConfig.php b/Helper/PricingAttributeConfig.php deleted file mode 100644 index 2ce347b..0000000 --- a/Helper/PricingAttributeConfig.php +++ /dev/null @@ -1,59 +0,0 @@ -useCustomerGroup)) { - $this->useCustomerGroup = $this->scopeConfig->isSetFlag(self::XML_PATH_USE_CUSTOMER_GROUP); - } - return $this->useCustomerGroup; - } - - /** - * @return bool - */ - public function getUseSiteVariant(): bool - { - if (!isset($this->useSiteVariantPricing)) { - $this->useSiteVariantPricing = parent::getUseSiteVariant() && - $this->scopeConfig->isSetFlag(self::XML_PATH_USE_SITE_VARIANT); - } - return $this->useSiteVariantPricing; - } - - /** - * @return bool - */ - public function getUseRange(): bool - { - if (!isset($this->useRange)) { - $this->useRange = $this->scopeConfig->isSetFlag(self::XML_PATH_USE_RANGE); - } - return $this->useRange; - } - - /** - * @return string[] - */ - public function getAllSiteVariantSuffixes(): array - { - return $this->getUseSiteVariant() ? parent::getAllSiteVariantSuffixes() : ['']; - } -} diff --git a/Helper/SanityCheckConfig.php b/Helper/SanityCheckConfig.php deleted file mode 100644 index 2fa7d35..0000000 --- a/Helper/SanityCheckConfig.php +++ /dev/null @@ -1,63 +0,0 @@ -scopeConfig->getValue(self::XML_PATH_MIN_TOTAL); - } - - /** - * @return int - */ - public function getMaxDeleteProducts(): int - { - return (int)$this->scopeConfig->getValue(self::XML_PATH_MAX_DELETE); - } - - /** - * @return int - */ - public function getMinProductsCategoryTier1(): int - { - return (int)$this->scopeConfig->getValue(self::XML_PATH_MIN_CATEGORY_TIER1); - } - - /** - * @return int - */ - public function getMinProductsCategoryTier2(): int - { - return (int)$this->scopeConfig->getValue(self::XML_PATH_MIN_CATEGORY_TIER2); - } - - /** - * @return array - */ - public function getErrorEmailRecipients(): array - { - $rawConfig = $this->scopeConfig->getValue(self::XML_PATH_REPORT_EMAIL) ?? ''; - $emailRecipients = []; - foreach (preg_split('/,\s*/', $rawConfig) as $recipient) { - if (!empty($recipient)) { - $emailRecipients[] = $recipient; - } - } - return $emailRecipients; - } -} diff --git a/Helper/StockAttributeConfig.php b/Helper/StockAttributeConfig.php deleted file mode 100644 index fd51430..0000000 --- a/Helper/StockAttributeConfig.php +++ /dev/null @@ -1,59 +0,0 @@ -sendStockStatus)) { - $this->sendStockStatus = $this->scopeConfig->isSetFlag(self::XML_PATH_SEND_STOCK_STATUS); - } - return $this->sendStockStatus; - } - - /** - * @return bool - */ - public function getSendStockCount(): bool - { - if (!isset($this->sendStockCount)) { - $this->sendStockCount = $this->scopeConfig->isSetFlag(self::XML_PATH_SEND_STOCK_COUNT); - } - return $this->sendStockCount; - } - - /** - * @return bool - */ - public function getUseSiteVariant(): bool - { - if (!isset($this->useSiteVariantStock)) { - $this->useSiteVariantStock = parent::getUseSiteVariant() && - $this->scopeConfig->isSetFlag(self::XML_PATH_USE_SITE_VARIANT); - } - return $this->useSiteVariantStock; - } - - /** - * @return string[] - */ - public function getAllSiteVariantSuffixes(): array - { - return $this->getUseSiteVariant() ? parent::getAllSiteVariantSuffixes() : ['']; - } -} diff --git a/Helper/SuggestConfig.php b/Helper/SuggestConfig.php deleted file mode 100644 index 483a1ce..0000000 --- a/Helper/SuggestConfig.php +++ /dev/null @@ -1,58 +0,0 @@ -json = $json; - } - - /** - * @return array - */ - public function getBlacklistSearchTerms(): array - { - if (!isset($this->blacklistSearchTerms)) { - $configValue = $this->scopeConfig->getValue(self::XML_PATH_BLACKLIST_TERMS); - $this->blacklistSearchTerms = $this->json->unserialize($configValue ?? '[]'); - } - return $this->blacklistSearchTerms; - } - - /** - * @return array - */ - public function getWhitelistSearchTerms(): array - { - if (!isset($this->whitelistSearchTerms)) { - $configValue = $this->scopeConfig->getValue(self::XML_PATH_WHITELIST_TERMS); - $this->whitelistSearchTerms = $this->json->unserialize($configValue ?? '[]'); - } - return $this->whitelistSearchTerms; - } -} diff --git a/Model/Export/AbstractProductExporter.php b/Model/Export/AbstractProductExporter.php deleted file mode 100644 index 90fa74a..0000000 --- a/Model/Export/AbstractProductExporter.php +++ /dev/null @@ -1,396 +0,0 @@ -products = $products; - $this->meta = $meta; - $this->zipFile = $zipFile; - $this->upload = $upload; - $this->config = $config; - $this->sanityConfig = $sanityConfig; - $this->emailHelper = $emailHelper; - $this->filesystem = $filesystem; - $this->json = $json; - $this->logger = $logger; - $this->productLimit = $productLimit; - } - - abstract public function export(): bool; - - abstract protected function getDirectory() : string; - - abstract protected function getZipFileName() : string; - - /** - * @param bool $isDryRun - * @return void - */ - public function setDryRun(bool $isDryRun): void - { - $this->upload->setDryRun($isDryRun); - } - - /** - * @param bool $isIncremental - * @return bool - * @throws FileSystemException - * @throws LocalizedException - */ - protected function doExport(bool $isIncremental) : bool - { - // create a new temp directory for files to be sent to fredhopper - $this->directory = $this->getDirectory(); - try { - $this->filesystem->createDirectory($this->directory); - } catch (\Exception $e) { - $this->logger->critical( - "Could not create directory $this->directory for export", - ['exception' => $e] - ); - return false; - } - - if (!$isIncremental) { - $metaContent = $this->generateMetaJson(); - if (!$metaContent) { - return false; - } - } - - $productIds = $this->products->getAllProductIds($isIncremental); - if (empty($productIds)) { - $this->logger->info('Product export has no products to process - exiting.'); - return true; - } - - $errs = []; - $productCount = count($productIds); - if (!$isIncremental) { - $minProducts = $this->sanityConfig->getMinTotalProducts(); - if ($productCount < $minProducts) { - $errs[] = "Full export has $productCount products, below minimum threshold of $minProducts"; - } else { - $msg = "Generating JSON for full export of $productCount products (meets minimum of $minProducts)"; - $this->logger->info($msg); - } - $this->generateCategoryValidationArray($metaContent); - } - - // generate JSON for export - $fileIndex = 0; - foreach (array_chunk($productIds, $this->productLimit) as $ids) { - $productData = $this->products->getProductData($ids, $isIncremental); - if (!$isIncremental) { - // add product category information for minimum count validation - $this->addProductsToCategoryCount($productData); - } else { - foreach ($productData as $product) { - $op = $product['operation'] ?? null; - if (!$op) { - continue; - } - $this->opCount[$op] = ($this->opCount[$op] ?? 0) + 1; - - // Collate SKUs to delete for inclusion in logging - if ($op != 'delete') { - continue; - } - foreach ($product['attributes'] as $attr) { - if ($attr['attribute_id'] != 'sku') { - continue; - } - $value = reset($attr['values']); - if (isset($value['value'])) { - $this->deleteSkus[] = $value['value']; - } - break; - } - } - } - if (!$this->generateProductsJson($productData, $fileIndex)) { - return false; - } - $fileIndex++; - } - - // ensure minimum category counts are met for full export - if (!$isIncremental) { - $errs = array_merge($errs, $this->validateCategories()); - if (count($errs) > 0) { - foreach ($errs as $err) { - $this->logger->error($err); - } - $this->logger->critical("Cancelling export due to errors"); - $recipients = $this->sanityConfig->getErrorEmailRecipients(); - if (count($recipients) > 0) { - $this->emailHelper->sendErrorEmail($recipients, $errs); - } - return false; - } - } else { - $msg = "Generating JSON for incremental export of $productCount products: "; - $msg .= $this->json->serialize($this->opCount); - $this->logger->info($msg); - - if (!empty($this->deleteSkus)) { - $msg = "Deleted SKUs: " . implode(', ', array_slice($this->deleteSkus, 0, 10)); - if (count($this->deleteSkus) > 10) { - $msg .= ', ...'; - } - $this->logger->info($msg); - } - } - - if ($this->config->getUseVariantProducts()) { - $variantIds = $this->products->getAllVariantIds($isIncremental); - if ($this->config->getDebugLogging()) { - $this->logger->debug('Generating JSON for ' . count($variantIds) . ' variants'); - } - $fileIndex = 0; - foreach (array_chunk($variantIds, $this->productLimit) as $ids) { - $variantData = $this->products->getVariantData($ids, $isIncremental); - if (!$this->generateVariantsJson($variantData, $fileIndex)) { - return false; - } - $fileIndex++; - } - } - - $zipFilePath = $this->directory . DIRECTORY_SEPARATOR . $this->getZipFileName(); - // send and trigger update - $success = $this->zipFile->createZipFile($zipFilePath, $this->files); - if ($success) { - $success = $this->upload->uploadZipFile($zipFilePath); - } - return $success; - } - - /** - * @return array[]|false - * @throws LocalizedException - */ - private function generateMetaJson() - { - $filePath = $this->directory . DIRECTORY_SEPARATOR . self::META_FILE; - $content = $this->meta->getMetaData(); - try { - $this->filesystem->filePutContents($filePath, $this->json->serialize($content)); - } catch (\Exception $e) { - // couldn't create a required file, so abort - $this->logger->critical( - "Error saving meta file $filePath", - ['exception' => $e] - ); - return false; - } - $this->files[] = $filePath; - return $content; - } - - /** - * @return string[] Errors found in categories, if any - */ - private function validateCategories(): array - { - $errors = []; - - $tierRequired = [ - 1 => $this->sanityConfig->getMinProductsCategoryTier1(), - 2 => $this->sanityConfig->getMinProductsCategoryTier2(), - ]; - - // Ensure that tier 1 & 2 categories all have sufficient products to meet the tier's threshold - $tierMin = []; - $sufficientProducts = true; - foreach ($this->categoriesForValidation as $cat) { - $tier = $cat['tier']; - if (!isset($tierMin[$tier]) || $cat['product_count'] < $tierMin[$tier]['product_count']) { - $tierMin[$tier] = $cat; - } - - $required = $tierRequired[$tier]; - if ($cat['product_count'] < $required) { - $errMsg = "Insufficient products in tier $tier category {$cat['name']}"; - $errMsg .= ": {$cat['product_count']} (expected $required)"; - $errors[] = $errMsg; - $sufficientProducts = false; - } - } - - if ($sufficientProducts) { - foreach ($tierMin as $tier => $cat) { - $msg = "Category {$cat['name']} has fewest products in tier $tier: {$cat['product_count']}"; - $this->logger->info($msg); - } - } - - return $errors; - } - - /** - * @param array $metaContent Format as per Data\Meta::getMetaData() - * @return void - */ - private function generateCategoryValidationArray(array $metaContent): void - { - $categories = []; - // Collate tier 1 and 2 categories from FH meta-data structure into flat array - foreach ($metaContent['meta']['attributes'] as $attr) { - if ($attr['attribute_id'] == 'categories') { - $catList = $attr['values'][0]['children'] ?? []; - foreach ($catList as $category) { - $catId = (int)$category['category_id']; - $catName = $category['names'][0]['name']; - $categories[$catId] = [ - 'id' => $catId, - 'name' => $catName, - 'tier' => 1, - 'parent' => null, - 'product_count' => 0, - ]; - foreach ($category['children'] as $child) { - $childCatId = (int)$child['category_id']; - $categories[$childCatId] = [ - 'id' => $childCatId, - 'name' => $catName . ' > ' . $child['names'][0]['name'], - 'tier' => 2, - 'parent' => $catId, - 'product_count' => 0, - ]; - } - } - break; - } - } - $this->categoriesForValidation = $categories; - } - - /** - * @param array $productData Format as per Data\Products::getProductData() - * @return void - */ - private function addProductsToCategoryCount(array $productData): void - { - // Count products in each tier 1/2 category - foreach ($productData as $product) { - foreach ($product['attributes'] as $attr) { - if ($attr['attribute_id'] != 'categories') { - continue; - } - foreach ($attr['values'] as $productCategory) { - $catId = (int)$productCategory['value']; - if (!isset($this->categoriesForValidation[$catId])) { - continue; - } - $this->categoriesForValidation[$catId]['product_count'] += 1; - } - } - } - } - - /** - * @param array $productData - * @param int $fileIndex - * @return bool - */ - private function generateProductsJson(array $productData, int $fileIndex): bool - { - $filePath = $this->directory . DIRECTORY_SEPARATOR . self::PRODUCT_FILE_PREFIX . $fileIndex . '.json'; - $content = ['products' => $productData]; - try { - $this->filesystem->filePutContents($filePath, $this->json->serialize($content)); - } catch (\Exception $e) { - $this->logger->critical( - "Error saving products file $filePath", - ['exception' => $e] - ); - return false; - } - $this->files[] = $filePath; - return true; - } - - /** - * @param array $variantData - * @param int $fileIndex - * @return bool - */ - private function generateVariantsJson(array $variantData, int $fileIndex): bool - { - $filePath = $this->directory . DIRECTORY_SEPARATOR . self::VARIANT_FILE_PREFIX . $fileIndex . '.json'; - $content = ['variants' => $variantData]; - try { - $this->filesystem->filePutContents($filePath, $this->json->serialize($content)); - } catch (\Exception $e) { - $this->logger->critical( - "Error saving variants file $filePath", - ['exception' => $e] - ); - return false; - } - $this->files[] = $filePath; - return true; - } -} diff --git a/Model/Export/Data/AbstractSearchTermsFileGenerator.php b/Model/Export/Data/AbstractSearchTermsFileGenerator.php deleted file mode 100644 index f9b7185..0000000 --- a/Model/Export/Data/AbstractSearchTermsFileGenerator.php +++ /dev/null @@ -1,72 +0,0 @@ -suggestConfig = $suggestConfig; - $this->fileSystem = $fileSystem; - } - - /** - * @inheritDoc - */ - public function generateFile(string $directory): string - { - $defaultLocale = $this->suggestConfig->getDefaultLocale(); - $searchTerms = $this->getSearchTerms(); - if (empty($searchTerms)) { - return ''; - } - - $filename = $directory . DIRECTORY_SEPARATOR . static::FILENAME; - $fileContent = $this->addRow(self::HEADER_ROW); - - foreach ($searchTerms as $searchTerm) { - $row = [$searchTerm['search_term'], $defaultLocale]; - $fileContent .= "\n"; - $fileContent .= $this->addRow($row); - } - - try { - $this->fileSystem->filePutContents($filename, $fileContent); - return $filename; - } catch (\Exception $e) { - return ''; - } - } - - /** - * @param array $data - * @return string - */ - private function addRow(array $data): string - { - $rowContent = ''; - foreach ($data as $column) { - $rowContent .= $column . self::DELIMITER; - } - // remove trailing delimiter - return rtrim($rowContent, self::DELIMITER); - } - - abstract protected function getSearchTerms() : array; -} diff --git a/Model/Export/Data/BlacklistFileGenerator.php b/Model/Export/Data/BlacklistFileGenerator.php deleted file mode 100644 index 5aa3bdd..0000000 --- a/Model/Export/Data/BlacklistFileGenerator.php +++ /dev/null @@ -1,17 +0,0 @@ -suggestConfig->getBlacklistSearchTerms(); - } -} diff --git a/Model/Export/Data/ImmediateProducts.php b/Model/Export/Data/ImmediateProducts.php deleted file mode 100644 index 7a7c04f..0000000 --- a/Model/Export/Data/ImmediateProducts.php +++ /dev/null @@ -1,215 +0,0 @@ -dimensionProvider = $dimensionProvider; - $this->indexerAction = $indexerAction; - $this->engineProvider = $engineProvider; - $this->dataHandlerFactory = $dataHandlerFactory; - $this->productResource = $productResource; - - parent::__construct( - $generalConfig, - $attributeConfig, - $pricingAttributeConfig, - $stockAttributeConfig, - $ageAttributeConfig, - $imageAttributeConfig, - $customAttributeConfig, - $json, - $resource, - $siteVariantPriceAttributes, - $siteVariantStockAttributes, - $siteVariantImageAttributes, - $siteVariantAgeAttributes - ); - $this->resource = $resource; - $this->json = $json; - } - - /** - * Set Skus - * - * @param array $skus - */ - public function setSkus(array $skus) - { - $this->skus = $skus; - } - - /** - * @inheritDoc - */ - public function getAllProductIds(bool $isIncremental): array - { - return $this->productResource->getProductsIdsBySkus($this->skus); - } - - /** - * @inheritDoc - */ - public function getAllVariantIds(bool $isIncremental): array - { - return $this->getAllProductIds($isIncremental); - } - - /** - * @inheritDoc - */ - protected function getRawProductData(array $productIds, bool $isIncremental, bool $isVariants) : array - { - $engine = $this->engineProvider->get(); - if (!($engine instanceof FredhopperEngine)) { - throw new \RuntimeException("Fredhopper is not configured as the search engine in Catalog Search"); - } - - /** @var DataHandler $dataHandler */ - $dataHandler = $this->dataHandlerFactory->create(); - - $products = []; - foreach ($this->dimensionProvider->getIterator() as $dimension) { - $scope = $dimension['scope']; - $scopeId = (int)$scope->getValue(); - $documentSource = $this->indexerAction->rebuildStoreIndex($scopeId, $productIds); - $documents = []; - foreach ($documentSource as $sourceKey => $document) { - $documents[$sourceKey] = $document; - } - $dataHandler->processDocuments($documents, $scopeId); - foreach ($documents as $docKey => $doc) { - if (!$isVariants) { - $product = [ - 'store_id' => $scopeId, - 'product_type' => 'p', - 'product_id' => $docKey, - 'parent_id' => null, - 'attribute_data' => $this->json->serialize($doc['product']), - 'operation_type' => 'a', - ]; - $products[] = $product; - } else { - if (empty($doc['variants'])) { - continue; - } - foreach ($doc['variants'] as $variantKey => $variant) { - $product = [ - 'store_id' => $scopeId, - 'product_type' => 'v', - 'product_id' => $variantKey, - 'parent_id' => $docKey, - 'attribute_data' => $this->json->serialize($variant), - 'operation_type' => 'a', - ]; - $products[] = $product; - } - } - } - } - - return $products; - } -} diff --git a/Model/Export/Data/Meta.php b/Model/Export/Data/Meta.php deleted file mode 100644 index e173300..0000000 --- a/Model/Export/Data/Meta.php +++ /dev/null @@ -1,421 +0,0 @@ -relevantCategory = $relevantCategory; - $this->customerGroupRepository = $customerGroupRepository; - $this->attributeConfig = $attributeConfig; - $this->searchCriteriaBuilder = $searchCriteriaBuilder; - $this->pricingAttributeConfig = $pricingAttributeConfig; - $this->stockAttributeConfig = $stockAttributeConfig; - $this->ageAttributeConfig = $ageAttributeConfig; - $this->imageAttributeConfig = $imageAttributeConfig; - $this->customAttributeConfig = $customAttributeConfig; - } - - /** - * @return array - * @throws LocalizedException - */ - public function getMetaData() : array - { - $attributesArray = array_merge( - $this->getAttributesArray(), - [ - [ - 'attribute_id' => 'categories', - 'type' => 'hierarchical', - 'values' => [ - $this->getCategoryArray() - ] - ] - ] - ); - return [ - 'meta' => [ - 'attributes' => array_values($attributesArray) - ] - ]; - } - - /** - * @throws LocalizedException - */ - private function getAttributesArray() : array - { - $attributeArray = []; - $defaultLocale = $this->attributeConfig->getDefaultLocale(); - $siteVariantSuffixes = $this->attributeConfig->getAllSiteVariantSuffixes(); - /** - * returns an array with the keys (only relevant keys listed): - * attribute, fredhopper_type, label - * - */ - $allAttributes = $this->attributeConfig->getAllAttributes(); - foreach ($allAttributes as $attributeData) { - if ($attributeData['append_site_variant']) { - $suffixes = $siteVariantSuffixes; - } else { - $suffixes = ['']; - } - foreach ($suffixes as $siteVariant => $suffix) { - $attributeArray[] = [ - 'attribute_id' => $attributeData['attribute'] . $suffix, - 'type' => $attributeData['fredhopper_type'], - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => $attributeData['label'] . (is_numeric($siteVariant) ? '' : (' ' . $siteVariant)) - ] - ] - ]; - } - } - - if ($this->attributeConfig->getUseSiteVariant()) { - $attributeArray[] = [ - 'attribute_id' => 'site_variant', - 'type' => FHAttributeTypes::ATTRIBUTE_TYPE_SET64, - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __('Site Variant') - ] - ] - ]; - } - - if ($this->pricingAttributeConfig->getUseCustomerGroup()) { - $attributeArray[] = [ - 'attribute_id' => 'customer_group', - 'type' => FHAttributeTypes::ATTRIBUTE_TYPE_SET, - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __('Customer Group') - ] - ] - ]; - } - - return array_merge( - $attributeArray, - $this->getPriceAttributesArray($defaultLocale), - $this->getStockAttributesArray($defaultLocale), - $this->getImageAttributesArray($defaultLocale), - $this->getAgeAttributesArray($defaultLocale), - $this->getCustomAttributesArray($defaultLocale) - ); - } - - /** - * @param string $defaultLocale - * @return array - * @throws LocalizedException - */ - private function getPriceAttributesArray(string $defaultLocale): array - { - $priceAttributes = [ - 'regular_price' => 'Regular Price', - 'special_price' => 'Special Price' - ]; - if ($this->pricingAttributeConfig->getUseRange()) { - $priceAttributes['min_price'] = 'Minimum Price'; - $priceAttributes['max_price'] = 'Maximum Price'; - } - // check for any custom attributes that are prices - foreach ($this->customAttributeConfig->getCustomAttributeData() as $attributeCode => $attributeData) { - if (($attributeData['type'] ?? null) === 'price') { - $priceAttributes[$attributeCode] = $attributeData['label']; - } - } - - $attributesArray = []; - $siteVariantSuffixes = $this->pricingAttributeConfig->getAllSiteVariantSuffixes(); - $suffixes = []; - $customerGroups = $this->customerGroupRepository->getList($this->searchCriteriaBuilder->create())->getItems(); - if ($this->pricingAttributeConfig->getUseCustomerGroup()) { - foreach ($customerGroups as $customerGroup) { - foreach ($siteVariantSuffixes as $siteVariant => $siteVariantSuffix) { - $key = $siteVariant . '_' . $customerGroup->getId(); - $suffixes[$key] = '_' . $customerGroup->getId() . $siteVariantSuffix; - } - } - } else { - $suffixes = $siteVariantSuffixes; - } - - foreach ($suffixes as $key => $suffix) { - foreach ($priceAttributes as $attributeCode => $label) { - $attributesArray[] = [ - 'attribute_id' => $attributeCode . $suffix, - 'type' => FHAttributeTypes::ATTRIBUTE_TYPE_FLOAT, - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __($label) . (is_numeric($key) ? '' : (' ' . $key)) - ] - ] - ]; - } - } - - return $attributesArray; - } - - /** - * @param string $defaultLocale - * @return array - */ - private function getStockAttributesArray(string $defaultLocale): array - { - $stockAttributes = []; - if ($this->stockAttributeConfig->getSendStockCount()) { - $stockAttributes['stock_qty'] = 'Stock Count'; - } - if ($this->stockAttributeConfig->getSendStockStatus()) { - $stockAttributes['stock_status'] = 'Stock Status'; - } - // check for any custom stock attributes - foreach ($this->customAttributeConfig->getCustomAttributeData() as $attributeCode => $attributeData) { - if (($attributeData['type'] ?? null) === 'stock') { - $stockAttributes[$attributeCode] = $attributeData['label']; - } - } - - $attributesArray = []; - $siteVariantSuffixes = $this->stockAttributeConfig->getAllSiteVariantSuffixes(); - - foreach ($siteVariantSuffixes as $siteVariant => $siteVariantSuffix) { - foreach ($stockAttributes as $attributeCode => $label) { - $attributesArray[] = [ - 'attribute_id' => $attributeCode . $siteVariantSuffix, - 'type' => FHAttributeTypes::ATTRIBUTE_TYPE_INT, - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __($label) . (is_numeric($siteVariant) ? '' : (' ' . $siteVariant)) - ] - ] - ]; - } - } - - return $attributesArray; - } - - /** - * @param string $defaultLocale - * @return array - */ - private function getImageAttributesArray(string $defaultLocale): array - { - $imageAttributes = [ - '_imageurl' => 'Image URL', - '_thumburl' => 'Thumbnail URL' - ]; - // check for custom image attributes - foreach ($this->customAttributeConfig->getCustomAttributeData() as $attributeCode => $attributeData) { - if (($attributeData['type'] ?? null) === 'image') { - $imageAttributes[$attributeCode] = $attributeData['label']; - } - } - - $attributeArray = []; - $suffixes = $this->imageAttributeConfig->getAllSiteVariantSuffixes(); - foreach ($suffixes as $siteVariant => $suffix) { - foreach ($imageAttributes as $attributeCode => $label) { - $attributeArray[] = [ - 'attribute_id' => $attributeCode . $suffix, - 'type' => FHAttributeTypes::ATTRIBUTE_TYPE_ASSET, - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __($label) . (is_numeric($siteVariant) ? '' : (' ' . $siteVariant)) - ] - ] - ]; - } - } - return $attributeArray; - } - - /** - * @param string $defaultLocale - * @return array - */ - private function getAgeAttributesArray(string $defaultLocale): array - { - $ageAttributes = []; - if ($this->ageAttributeConfig->getSendNewIndicator()) { - $ageAttributes['is_new'] = 'New'; - } - if ($this->ageAttributeConfig->getSendDaysOnline()) { - $ageAttributes['days_online'] = 'Newest Products'; // label used for sorting - } - - $attributesArray = []; - $siteVariantSuffixes = $this->ageAttributeConfig->getAllSiteVariantSuffixes(); - foreach ($siteVariantSuffixes as $siteVariant => $siteVariantSuffix) { - foreach ($ageAttributes as $attributeCode => $label) { - $attributesArray[] = [ - 'attribute_id' => $attributeCode . $siteVariantSuffix, - 'type' => FHAttributeTypes::ATTRIBUTE_TYPE_INT, - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __($label) . (is_numeric($siteVariant) ? '' : (' ' . $siteVariant)) - ] - ] - ]; - } - } - - return $attributesArray; - } - - /** - * @param string $defaultLocale - * @return array - */ - private function getCustomAttributesArray(string $defaultLocale): array - { - $attributesArray = []; - $siteVariantSuffixes = $this->attributeConfig->getAllSiteVariantSuffixes(); - foreach ($this->customAttributeConfig->getCustomAttributeData() as $customAttribute) { - // check if attribute has already been processed as price/stock/image attribute - if (!empty($customAttribute['type'])) { - continue; - } - - if ($customAttribute['is_site_variant'] ?? false) { - foreach ($siteVariantSuffixes as $siteVariant => $siteVariantSuffix) { - $attributesArray[] = [ - 'attribute_id' => $customAttribute['attribute_code'] . $siteVariantSuffix, - 'type' => $customAttribute['fredhopper_type'], - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __($customAttribute['label']) . - (is_numeric($siteVariant) ? '' : (' ' . $siteVariant)) - ] - ] - ]; - } - } else { - $attributesArray[] = [ - 'attribute_id' => $customAttribute['attribute_code'], - 'type' => $customAttribute['fredhopper_type'], - 'names' => [ - [ - 'locale' => $defaultLocale, - 'name' => __($customAttribute['label']) - ] - ] - ]; - } - } - return $attributesArray; - } - - /** - * @return string[] - */ - private function getCategoryArray() : array - { - $categoryCollection = $this->relevantCategory->getCollection(); - - $allCategories = $categoryCollection->getItems(); - - /** @var Category $rootCategory */ - $this->rootCategoryId = $this->attributeConfig->getRootCategoryId(); - $rootCategory = $allCategories[$this->rootCategoryId] ?? null; - return $this->getCategoryDataWithChildren($rootCategory, $allCategories); - } - - /** - * @param Category $category - * @param array $allCategories - * @return string[] - */ - private function getCategoryDataWithChildren( - Category $category, - array $allCategories - ) : array { - $categoryId = $category->getId(); - $categoryData = [ - 'category_id' => ($categoryId == $this->rootCategoryId ? self::ROOT_CATEGORY_NAME : $categoryId) - ]; - $names =[ - [ - 'locale' => $this->attributeConfig->getDefaultLocale(), - 'name' => $allCategories[$category->getId()]->getName() - ] - ]; - $categoryData['names'] = $names; - - // add child category information - $children = []; - foreach (explode(',', $category->getChildren()) as $child) { - if (!empty($child)) { - $children[] = $child; - } - } - if (empty($children)) { - $categoryData['children'] = []; - } else { - $childArray = []; - foreach ($children as $childId) { - if (!isset($allCategories[$childId])) { - continue; - } - $childCategory = $allCategories[$childId]; - $childArray[] = $this->getCategoryDataWithChildren($childCategory, $allCategories); - } - $categoryData['children'] = $childArray; - } - return $categoryData; - } -} diff --git a/Model/Export/Data/Products.php b/Model/Export/Data/Products.php deleted file mode 100644 index f35b780..0000000 --- a/Model/Export/Data/Products.php +++ /dev/null @@ -1,464 +0,0 @@ - self::OPERATION_TYPE_ADD, - DataHandler::OPERATION_TYPE_UPDATE => self::OPERATION_TYPE_UPDATE, - DataHandler::OPERATION_TYPE_REPLACE => self::OPERATION_TYPE_REPLACE, - DataHandler::OPERATION_TYPE_DELETE => self::OPERATION_TYPE_DELETE - ]; - - private GeneralConfig $generalConfig; - private AttributeConfig $attributeConfig; - private PricingAttributeConfig $pricingAttributeConfig; - private StockAttributeConfig $stockAttributeConfig; - private AgeAttributeConfig $ageAttributeConfig; - private ImageAttributeConfig $imageAttributeConfig; - private CustomAttributeConfig $customAttributeConfig; - private Json $json; - private ResourceConnection $resource; - /** - * @var string[] - */ - private array $siteVariantPriceAttributes = [ - 'regular_price', - 'special_price', - 'min_price', - 'max_price' - ]; - /** - * @var string[] - */ - private array $siteVariantStockAttributes = [ - 'stock_qty', - 'stock_status' - ]; - /** - * @var string[] - */ - private array $siteVariantImageAttributes = [ - '_imageurl', - '_thumburl' - ]; - /** - * @var string[] - */ - private array $siteVariantAgeAttributes = [ - 'is_new', - 'days_online' - ]; - - public function __construct( - GeneralConfig $generalConfig, - AttributeConfig $attributeConfig, - PricingAttributeConfig $pricingAttributeConfig, - StockAttributeConfig $stockAttributeConfig, - AgeAttributeConfig $ageAttributeConfig, - ImageAttributeConfig $imageAttributeConfig, - CustomAttributeConfig $customAttributeConfig, - Json $json, - ResourceConnection $resource, - $siteVariantPriceAttributes = [], - $siteVariantStockAttributes = [], - $siteVariantImageAttributes = [], - $siteVariantAgeAttributes = [] - ) { - $this->generalConfig = $generalConfig; - $this->attributeConfig = $attributeConfig; - $this->pricingAttributeConfig = $pricingAttributeConfig; - $this->stockAttributeConfig = $stockAttributeConfig; - $this->ageAttributeConfig = $ageAttributeConfig; - $this->imageAttributeConfig = $imageAttributeConfig; - $this->customAttributeConfig = $customAttributeConfig; - $this->json = $json; - $this->resource = $resource; - - $this->siteVariantPriceAttributes = $this->pricingAttributeConfig->getUseSiteVariant() ? - array_merge($this->siteVariantPriceAttributes, $siteVariantPriceAttributes) : []; - $this->siteVariantStockAttributes = $this->stockAttributeConfig->getUseSiteVariant() ? - array_merge($this->siteVariantStockAttributes, $siteVariantStockAttributes) : []; - $this->siteVariantImageAttributes = $this->imageAttributeConfig->getUseSiteVariant() ? - array_merge($this->siteVariantImageAttributes, $siteVariantImageAttributes) : []; - $this->siteVariantAgeAttributes = $this->ageAttributeConfig->getUseSiteVariant() ? - array_merge($this->siteVariantAgeAttributes, $siteVariantAgeAttributes) : []; - } - - /** - * @param bool $isIncremental - * @return array - */ - public function getAllProductIds(bool $isIncremental): array - { - return $this->getAllIds($isIncremental, false); - } - - /** - * @param bool $isIncremental - * @return array - */ - public function getAllVariantIds(bool $isIncremental): array - { - return $this->getAllIds($isIncremental, true); - } - - /** - * @param bool $isIncremental - * @param bool $isVariants - * @return array - */ - private function getAllIds(bool $isIncremental, bool $isVariants): array - { - $productType = $isVariants ? self::PRODUCT_TYPE_VARIANT : self::PRODUCT_TYPE_PRODUCT; - $connection = $this->resource->getConnection(); - $select = $connection->select(); - $select->from( - DataHandler::INDEX_TABLE_NAME, - ['product_id' => 'product_id'] - ); - $select->where("product_type = ?", $productType); - if ($isIncremental) { - $select->where('operation_type is not null'); - } else { - $select->where("ifnull(operation_type, '') <> 'd'"); - } - $select->distinct(); - - return $connection->fetchCol($select); - } - - /** - * @param array $productIds - * @param bool $isIncremental - * @return array - */ - public function getProductData(array $productIds, bool $isIncremental): array - { - return $this->getProcessedProductData($productIds, $isIncremental); - } - - /** - * @param array $productIds - * @param bool $isIncremental - * @return array - */ - public function getVariantData(array $productIds, bool $isIncremental): array - { - return $this->getProcessedProductData($productIds, $isIncremental, true); - } - - /** - * @param array $productIds - * @param bool $isIncremental - * @param bool $isVariants - * @return array - */ - private function getProcessedProductData(array $productIds, bool $isIncremental, bool $isVariants = false): array - { - $rawProductData = $this->getRawProductData($productIds, $isIncremental, $isVariants); - return $this->processProductData( - $rawProductData, - $isVariants, - $isIncremental - ); - } - - /** - * @param array $productIds - * @param bool $isIncremental - * @param bool $isVariants - * @return array - */ - protected function getRawProductData(array $productIds, bool $isIncremental, bool $isVariants) : array - { - $productType = $isVariants ? self::PRODUCT_TYPE_VARIANT : self::PRODUCT_TYPE_PRODUCT; - $connection = $this->resource->getConnection(); - $select = $connection->select() - ->from(DataHandler::INDEX_TABLE_NAME) - ->columns([ - 'store_id' => 'store_id', - 'product_type' => 'product_type', - 'product_id' => 'product_id', - 'parent_id' => 'parent_id', - 'attribute_data' => 'attribute_data' - ]) - ->where("product_type = ?", $productType) - ->where('product_id in (?)', $productIds); - if ($isIncremental) { - $select->where('operation_type is not null'); - } else { - $select->where("ifnull(operation_type, '') <> 'd'"); - } - - return $connection->fetchAll($select); - } - - /** - * Processes product data and returns array ready for export - * @param array $rawProductData - * @param bool $isVariants - * @param bool $isIncremental - * @return array - */ - private function processProductData(array $rawProductData, bool $isVariants, bool $isIncremental) : array - { - // collect store records for each product into a single array - $productStoreData = $this->collateProductData($rawProductData); - - return $this->convertProductDataToFredhopperFormat( - $productStoreData, - $isVariants, - $isIncremental - ); - } - - /** - * Combines all database records for each product into a single array. - * @param array $productData - * @return array - */ - private function collateProductData(array $productData): array - { - $defaultStore = $this->generalConfig->getDefaultStore(); - $productStoreData = []; - foreach ($productData as $row) { - $productStoreData[$row['product_id']] = $productStoreData[$row['product_id']] ?? []; - $productStoreData[$row['product_id']]['stores'][$row['store_id']] = - $this->json->unserialize($row['attribute_data']); - $productStoreData[$row['product_id']]['parent_id'] = $row['parent_id']; - $productStoreData[$row['product_id']]['operation_type'] = $row['operation_type']; - // handle the case where a product does not belong to the default store - if (!isset($productStoreData[$row['product_id']]['default_store']) || $row['store_id'] === $defaultStore) { - $productStoreData[$row['product_id']]['default_store'] = (int)$row['store_id']; - } - } - return $productStoreData; - } - - /** - * @param array $productStoreData - * @param bool $isVariants - * @param bool $isIncremental - * @return array - */ - private function convertProductDataToFredhopperFormat( - array $productStoreData, - bool $isVariants, - bool $isIncremental - ): array { - $defaultLocale = $this->generalConfig->getDefaultLocale(); - $products = []; - foreach ($productStoreData as $productId => $productData) { - $defaultStore = $productData['default_store']; - $product = [ - 'product_id' => "{$this->generalConfig->getProductPrefix()}$productId", - 'attributes' => $this->convertAttributeDataToFredhopperFormat( - $productData, - $defaultStore, - $defaultLocale, - $isVariants - ), - 'locales' => [ - $defaultLocale - ] - ]; - if ($isVariants) { - $product['product_id'] = "{$this->generalConfig->getProductPrefix()}{$productData['parent_id']}"; - $product['variant_id'] = "{$this->generalConfig->getVariantPrefix()}$productId"; - } - if ($isIncremental) { - $product['operation'] = self::OPERATION_TYPE_MAPPING[$productData['operation_type']]; - } - $products[] = $product; - } - return $products; - } - - /** - * Converts product attribute data from multiple stores into a single array in the correct format for fredhopper - * @param array $productData - * @param int $defaultStore - * @param string $defaultLocale - * @param bool $isVariants - * @return array - */ - private function convertAttributeDataToFredhopperFormat( - array $productData, - int $defaultStore, - string $defaultLocale, - bool $isVariants - ): array { - $attributes = []; - $categories = []; - foreach ($productData['stores'] as $storeId => $storeData) { - // convert to correct format for fredhopper export - foreach ($storeData as $attributeCode => $attributeValues) { - // handle categories separately - if ($attributeCode === 'categories') { - $categories[] = $attributeValues; - continue; - } - if (!is_array($attributeValues)) { - $attributeValues = [$attributeValues]; - } - $addLocale = false; - $fredhopperType = $this->getAttributeFredhopperTypeByCode($attributeCode); - // for "localisable" types, need to add locale information - switch ($fredhopperType) { - case FHAttributeTypes::ATTRIBUTE_TYPE_LIST: - case FHAttributeTypes::ATTRIBUTE_TYPE_LIST64: - case FHAttributeTypes::ATTRIBUTE_TYPE_SET: - case FHAttributeTypes::ATTRIBUTE_TYPE_SET64: - case FHAttributeTypes::ATTRIBUTE_TYPE_ASSET: - // add locale to attribute data - $addLocale = true; - break; - case FHAttributeTypes::ATTRIBUTE_TYPE_INT: - case FHAttributeTypes::ATTRIBUTE_TYPE_FLOAT: - case FHAttributeTypes::ATTRIBUTE_TYPE_TEXT: - break; - default: - // invalid attribute type - remove from array - unset($storeData[$attributeCode]); - continue 2; - } - - $values = []; - foreach ($attributeValues as $value) { - $valueEntry = [ - 'value' => (string)$value // ensure all values are strings - ]; - if ($addLocale) { - $valueEntry['locale'] = $defaultLocale; - } - $values[] = $valueEntry; - } - - // will return attribute code with site variant if required - // return false if non-site-variant attribute in non-default store - $attributeId = $this->appendSiteVariantIfNecessary($attributeCode, $storeId, $defaultStore); - if ($attributeId) { - $attributes[] = [ - 'attribute_id' => $attributeId, - 'values' => $values - ]; - } - } - } - // collate categories from all stores - only for products - if (!$isVariants) { - $categories = array_unique(array_merge(...$categories)); - $categoryValues = []; - foreach ($categories as $category) { - $categoryValues[] = [ - 'value' => (string)$category, - 'locale' => $defaultLocale - ]; - } - $attributes[] = [ - 'attribute_id' => 'categories', - 'values' => $categoryValues - ]; - } - return $attributes; - } - - /** - * Returns the fredhopper attribute type for the given attribute code - * Returns false is the type cannot be found - * @param string $attributeCode - * @return bool|string - */ - private function getAttributeFredhopperTypeByCode(string $attributeCode) - { - // categories attribute is hierarchical - if ($attributeCode === 'categories') { - return FHAttributeTypes::ATTRIBUTE_TYPE_HIERARCHICAL; - } - // check custom attribute configuration - foreach ($this->customAttributeConfig->getCustomAttributeData() as $attributeData) { - if ($attributeData['attribute_code'] === $attributeCode) { - return $attributeData['fredhopper_type']; - } - } - // all price attributes are floats - if (strpos($attributeCode, 'price') !== false) { - return FHAttributeTypes::ATTRIBUTE_TYPE_FLOAT; - } - // all stock and age attributes are ints (boolean -> 1/0 for indicators) - if (strpos($attributeCode, 'stock') !== false || - $attributeCode === 'is_new' || $attributeCode === 'days_online') { - return FHAttributeTypes::ATTRIBUTE_TYPE_INT; - } - // all url attributes are assets - if (strpos($attributeCode, 'url') !== false) { - return FHAttributeTypes::ATTRIBUTE_TYPE_ASSET; - } - return $this->attributeConfig->getAttributesWithFredhopperType()[$attributeCode] ?? false; - } - - /** - * Returns attribute code with site variant appended if the attribute is configured to vary by site - * Otherwise, returns unchanged code for default store, false for any other store - * @param string $attributeCode - * @param int $storeId - * @param int $defaultStoreId - * @return bool|string - */ - private function appendSiteVariantIfNecessary(string $attributeCode, int $storeId, int $defaultStoreId) - { - $siteVariantAttributes = $this->attributeConfig->getSiteVariantAttributes(); - if ($this->generalConfig->getUseSiteVariant()) { - $siteVariant = $this->generalConfig->getSiteVariant($storeId); - if (in_array($attributeCode, $siteVariantAttributes) || - in_array($attributeCode, $this->siteVariantStockAttributes) || - in_array($attributeCode, $this->siteVariantImageAttributes) || - in_array($attributeCode, $this->siteVariantAgeAttributes) || - in_array($attributeCode, $this->customAttributeConfig->getSiteVariantCustomAttributes()) || - $this->isSiteVariantPriceAttribute($attributeCode)) { - return "{$attributeCode}_$siteVariant"; - } - } - // when not using store variants, only retain attributes in the default store - return $storeId === $defaultStoreId ? $attributeCode : false; - } - - /** - * Price attributes may have a suffix (e.g. regular_price_min), so use strpos for comparison - * @param string $attributeCode - * @return bool - */ - private function isSiteVariantPriceAttribute(string $attributeCode): bool - { - foreach ($this->siteVariantPriceAttributes as $code) { - if (strpos($attributeCode, $code) === 0) { - return true; - } - } - return false; - } -} diff --git a/Model/Export/Data/WhitelistFileGenerator.php b/Model/Export/Data/WhitelistFileGenerator.php deleted file mode 100644 index f42d404..0000000 --- a/Model/Export/Data/WhitelistFileGenerator.php +++ /dev/null @@ -1,17 +0,0 @@ -suggestConfig->getWhitelistSearchTerms(); - } -} diff --git a/Model/Export/FullExporter.php b/Model/Export/FullExporter.php deleted file mode 100644 index c1df475..0000000 --- a/Model/Export/FullExporter.php +++ /dev/null @@ -1,44 +0,0 @@ -logger->info('Performing full product export'); - $success = $this->doExport(false); - $this->logger->info('Full product export ' . ($success ? 'completed successfully' : 'failed')); - return $success; - } - - /** - * @return string - */ - protected function getDirectory(): string - { - if (!isset($this->directory)) { - $this->directory = '/tmp/fh_export_' . time(); - } - return $this->directory; - } - - /** - * @return string - */ - protected function getZipFileName(): string - { - return self::ZIP_FILE_FULL; - } -} diff --git a/Model/Export/IncrementalExporter.php b/Model/Export/IncrementalExporter.php deleted file mode 100644 index 94b20df..0000000 --- a/Model/Export/IncrementalExporter.php +++ /dev/null @@ -1,89 +0,0 @@ -dataHandler = $dataHandler; - $this->resetIndexTable = $resetIndexTable; - } - - /** - * @return bool - * @throws FileSystemException - * @throws LocalizedException - */ - public function export(): bool - { - $this->logger->info('Performing incremental data export'); - $success = $this->doExport(true); - if ($success && $this->resetIndexTable) { - $success = $this->dataHandler->resetIndexAfterExport(); - } - $this->logger->info('Incremental product export ' . ($success ? 'completed successfully' : 'failed')); - return $success; - } - - /** - * @return string - */ - protected function getDirectory(): string - { - if (!isset($this->directory)) { - $this->directory = '/tmp/fh_export_incremental_' . time(); - } - return $this->directory; - } - - /** - * @return string - */ - protected function getZipFileName(): string - { - return self::ZIP_FILE_INCREMENTAL; - } -} diff --git a/Model/Export/SuggestExporter.php b/Model/Export/SuggestExporter.php deleted file mode 100644 index ad9e91b..0000000 --- a/Model/Export/SuggestExporter.php +++ /dev/null @@ -1,77 +0,0 @@ -fileGenerators = $fileGenerators; - $this->zipFile = $zipFile; - $this->filesystem = $filesystem; - $this->upload = $upload; - $this->logger = $logger; - } - - /** - * @throws FileSystemException - */ - public function export() : bool - { - $this->logger->info('Performing suggest export'); - $directory = '/tmp/fh_export_suggest_' . time(); - try { - $this->filesystem->createDirectory($directory); - } catch (\Exception $e) { - $this->logger->critical( - "Could not create directory $directory for export", - ['exception' => $e] - ); - return false; - } - $files = []; - foreach ($this->fileGenerators as $fileGenerator) { - $file = $fileGenerator->generateFile($directory); - if (!empty($file)) { - $files[] = $file; - } - } - if (empty($files)) { - $this->logger->info('Suggest export has no files to process - exiting.'); - return true; - } - - $zipFilePath = $directory . DIRECTORY_SEPARATOR . self::ZIP_FILE_NAME; - $success = $this->zipFile->createZipFile($zipFilePath, $files); - if ($success) { - $success = $this->upload->uploadZipFile($zipFilePath); - } - $this->logger->info('Suggest export '. ($success ? 'completed successfully' : 'failed')); - return $success; - } -} diff --git a/Model/Export/Upload/AbstractUpload.php b/Model/Export/Upload/AbstractUpload.php deleted file mode 100644 index 7b91cf1..0000000 --- a/Model/Export/Upload/AbstractUpload.php +++ /dev/null @@ -1,215 +0,0 @@ -httpClient = $httpClient; - $this->generalConfig = $generalConfig; - $this->file = $file; - $this->filesystemDriver = $filesystemDriver; - $this->logger = $logger; - } - - /** - * @param bool $isDryRun - * @return void - */ - public function setDryRun(bool $isDryRun): void - { - $this->dryRun = $isDryRun; - } - - /** - * @param string $zipFilePath - * @return bool - * @throws FileSystemException - */ - public function uploadZipFile(string $zipFilePath): bool - { - if ($this->generalConfig->getDebugLogging()) { - $this->logger->debug("Uploading zip file: $zipFilePath"); - } - $zipContent = $this->filesystemDriver->fileGetContents($zipFilePath); - // md5 used for checksum, not for hashing password or secret information - // phpcs:ignore Magento2.Security.InsecureFunction.FoundWithAlternative - $checksum = md5($zipContent); - if ($this->generalConfig->getDebugLogging()) { - $this->logger->debug("Checksum of file: $checksum"); - } - $url = $this->getUploadUrl($zipFilePath); - $parameters = [ - 'headers' => [ - 'Content-Type: application/zip' - ], - 'body' => $zipContent, - 'query' => [ - 'checksum' => $checksum - ] - ]; - $request = $this->generateRequest($url, $parameters); - - $response = $this->sendRequest($request); - if (!$response) { - return false; - } - - // Treat any response code other than 2xx as an error - $statusString = (string)$response['status_code']; - if (strlen($statusString) < 1 || $statusString[0] !== '2') { - $this->logger->error("HTTP error: $statusString"); - return false; - } - - // get data id from the response body - $dataIdString = $response['body']; - if ($dataIdString) { - return $this->triggerDataLoad($dataIdString); - } - - return false; - } - - /** - * @param $dataIdString - * @return bool - */ - private function triggerDataLoad($dataIdString): bool - { - if ($this->generalConfig->getDebugLogging()) { - $this->logger->debug("Triggering load of data: $dataIdString"); - } - $parameters = [ - 'headers' => [ - 'Content-Type: text/plain' - ], - 'body' => $dataIdString - ]; - $request = $this->generateRequest($this->getTriggerUrl(), $parameters); - $response = $this->sendRequest($request); - // check that the data load was triggered correctly - return (isset($response['status_code']) && $response['status_code'] == 201); - } - - /** - * @param string $url - * @param array $parameters - * @return Request - */ - private function generateRequest(string $url, array $parameters): Request - { - $request = $this->httpClient->getRequest(); - $request->setMethod(Request::METHOD_PUT); - $request->setUri($url); - if (isset($parameters['headers'])) { - $headers = $request->getHeaders(); - $headers->addHeaders($parameters['headers']); - $request->setHeaders($headers); - } - if (isset($parameters['body'])) { - $request->setContent($parameters['body']); - } - if (isset($parameters['query'])) { - $queryParams = $request->getQuery(); - foreach ($parameters['query'] as $name => $value) { - $queryParams->set($name, $value); - } - $request->setQuery($queryParams); - } - return $request; - } - - private function getUploadUrl($filePath): string - { - $fileInfo = $this->file->getPathInfo($filePath); - $fileName = $fileInfo['basename']; - return $this->getBaseUrl() . '/data/input/' .$fileName; - } - - private function getTriggerUrl(): string - { - return $this->getBaseUrl() . '/trigger/' . $this->getFredhopperTriggerEndpoint(); - } - - private function getBaseUrl(): string - { - return 'https://my.' . $this->generalConfig->getEndpointName() . '.fredhopperservices.com/' . - $this->getFredhopperUploadEndpoint() .':' . $this->generalConfig->getEnvironmentName(); - } - - /** - * @return string - */ - abstract protected function getFredhopperUploadEndpoint() : string; - - /** - * @return string - */ - abstract protected function getFredhopperTriggerEndpoint() : string; - - /** - * @param $request - * @return array|false - */ - private function sendRequest($request) - { - if ($this->dryRun) { - $this->logger->info("Dry run; not exporting"); - return false; - } - $auth = $this->getAuth(); - $this->httpClient->setAuth($auth['username'], $auth['password']); - - $response = $this->httpClient->send($request); - if ($this->generalConfig->getDebugLogging()) { - $this->logger->debug("Request response:\n $response"); - } - // clear client for next request - $this->httpClient->reset(); - return [ - 'status_code' => $response->getStatusCode(), - 'body' => $response->getBody() - ]; - } - - /** - * @return array - */ - private function getAuth(): array - { - return [ - 'username' => $this->generalConfig->getUsername(), - 'password' => $this->generalConfig->getPassword() - ]; - } -} diff --git a/Model/Export/Upload/FasUpload.php b/Model/Export/Upload/FasUpload.php deleted file mode 100644 index b41da30..0000000 --- a/Model/Export/Upload/FasUpload.php +++ /dev/null @@ -1,24 +0,0 @@ -sanityCheckConfig = $sanityCheckConfig; - $this->resourceConnection = $resourceConnection; - } - - /** - * @inheritDoc - * @throws \Zend_Db_Statement_Exception - */ - public function validateState() - { - // check the number of deleted products does not reach the threshold - $connection = $this->resourceConnection->getConnection(); - $select = $connection->select() - ->from(DataHandler::INDEX_TABLE_NAME) - ->reset(Zend_Db_Select::COLUMNS) - ->columns(['store_id', 'product_count' => 'count(1)']) - ->where('product_type = ?', DataHandler::TYPE_PRODUCT) - ->where('operation_type = ?', DataHandler::OPERATION_TYPE_DELETE) - ->group(['store_id']) - ->order(['product_count DESC', 'store_id']) - ->limit(1); - $result = $connection->query($select); - $row = $result->fetch(); - - // if no rows exist in the table, will be handled by min total products config during export - if (!$row) { - return; - } - - $maxDeletes = $this->sanityCheckConfig->getMaxDeleteProducts(); - if ($row['product_count'] > $maxDeletes) { - throw new ValidationException( - __( - 'Number of deleted products (%1) in store %2 exceeds threshold (%3)', - $row['product_count'], - $row['store_id'], - $maxDeletes - ) - ); - } - } -} diff --git a/Model/Indexer/Data/AgeFieldsProvider.php b/Model/Indexer/Data/AgeFieldsProvider.php deleted file mode 100644 index cb68ffb..0000000 --- a/Model/Indexer/Data/AgeFieldsProvider.php +++ /dev/null @@ -1,65 +0,0 @@ -productCollectionFactory = $productCollectionFactory; - $this->ageAttributeConfig = $ageAttributeConfig; - $this->currentTime = time(); - } - /** - * @inheritDoc - */ - public function getFields(array $productIds, $storeId): array - { - if (!$this->ageAttributeConfig->getSendNewIndicator() && !$this->ageAttributeConfig->getSendDaysOnline()) { - return []; - } - $createdAtFieldName = $this->ageAttributeConfig->getCreatedAtFieldName(); - $productCollection = $this->productCollectionFactory->create(); - $productCollection->addIdFilter($productIds); - $productCollection->addStoreFilter($storeId); - if ($this->ageAttributeConfig->getSendNewIndicator()) { - $productCollection->addAttributeToSelect('news_from_date'); - } - if ($this->ageAttributeConfig->getSendDaysOnline()) { - $productCollection->addAttributeToSelect($createdAtFieldName); - } - $results = []; - $products = $productCollection->getItems(); - foreach ($products as $productId => $product) { - if ($this->ageAttributeConfig->getSendNewIndicator()) { - // product is considered new as long as it has a news_from_date value - $results[$productId]['is_new'] = (int)((bool)$product->getData('news_from_date')); // boolean as 1/0 - } - if ($this->ageAttributeConfig->getSendDaysOnline()) { - $createdTimestamp = $product->getData($createdAtFieldName); - if ($createdTimestamp === null) { - $results[$productId]['days_online'] = 0; - continue; - } - $createdTime = strtotime($createdTimestamp); - // convert seconds to days (rounded down) - $daysOnline = (int)(($this->currentTime - $createdTime) / (60 * 60 * 24)); - $results[$productId]['days_online'] = $daysOnline; - } - } - return $results; - } -} diff --git a/Model/Indexer/Data/FredhopperDataProvider.php b/Model/Indexer/Data/FredhopperDataProvider.php deleted file mode 100644 index 3efebd2..0000000 --- a/Model/Indexer/Data/FredhopperDataProvider.php +++ /dev/null @@ -1,265 +0,0 @@ -resource = $resource; - $this->searchDataProvider = $dataProvider; - $this->additionalFieldsProvider = $additionalFieldsProvider; - $this->catalogProductStatus = $catalogProductStatus; - $this->attributeConfig = $attributeConfig; - $this->productMapper = $productMapper; - $this->batchSize = $batchSize; - } - - /** - * @param $storeId - * @param $productIds - * @return \Generator - */ - public function rebuildStoreIndex($storeId, $productIds) : \Generator - { - // ensure store id is an integer - $storeId = (int)$storeId; - // check if store is excluded from indexing - if (in_array($storeId, $this->attributeConfig->getExcludedStores())) { - return; - } - if ($productIds !== null) { - $productIds = array_unique($productIds); - } - - $lastProductId = 0; - $staticAttributes = $this->attributeConfig->getStaticAttributes(); - $products = $this->searchDataProvider->getSearchableProducts( - $storeId, - [], - $productIds, - $lastProductId, - $this->batchSize - ); - - while (count($products) > 0) { - $allProductIds = array_column($products, 'entity_id'); - $relatedProducts = $this->getRelatedProducts($products); - $relatedProductIds = []; - foreach ($relatedProducts as $relatedArray) { - $relatedProductIds[] = $relatedArray; - } - $allProductIds = array_merge($allProductIds, ...$relatedProductIds); - - // ensure that status attribute is always included - $eavAttributesByType = $this->attributeConfig->getEavAttributesByType(); - $statusAttribute = $this->searchDataProvider->getSearchableAttribute('status'); - $eavAttributesByType['int'][] = $statusAttribute->getAttributeId(); - $productsAttributes = $this->searchDataProvider->getProductAttributes( - $storeId, - $allProductIds, - $eavAttributesByType - ); - - // Static field data are not included in searchDataProvider::getProductAttributes - $this->addStaticAttributes($productsAttributes, $staticAttributes); - - $additionalFields = $this->additionalFieldsProvider->getFields($allProductIds, $storeId); - - foreach ($products as $productData) { - $lastProductId = (int)$productData['entity_id']; - if (!isset($productsAttributes[$lastProductId])) { - continue; - } - $productIndex = [ - $lastProductId => $productsAttributes[$lastProductId] - ]; - if (isset($relatedProducts[$lastProductId])) { - $childProductsIndex = $this->getChildProductsIndex( - $lastProductId, - $relatedProducts, - $productsAttributes - ); - $productIndex = $productIndex + $childProductsIndex; - } - $index = $this->prepareProductIndex($productIndex, $productData, $storeId, $additionalFields); - yield $lastProductId => $index; - } - $products = $this->searchDataProvider->getSearchableProducts( - $storeId, - [], - $productIds, - $lastProductId, - $this->batchSize - ); - } - } - - /** - * @param int $parentId - * @param array $relatedProducts - * @param array $productsAttributes - * @return array - */ - private function getChildProductsIndex( - int $parentId, - array $relatedProducts, - array $productsAttributes - ) : array { - $productIndex = []; - - foreach ($relatedProducts[$parentId] as $productChildId) { - if ($this->isProductEnabled($productChildId, $productsAttributes)) { - $productIndex[$productChildId] = $productsAttributes[$productChildId]; - } - } - return $productIndex; - } - - /** - * @param $products - * @return array - */ - private function getRelatedProducts($products): array - { - $relatedProducts = []; - foreach ($products as $productData) { - $entityId = (int)$productData['entity_id']; - $relatedProducts[$entityId] = $this->searchDataProvider->getProductChildIds( - $entityId, - $productData['type_id'] - ); - } - return array_filter($relatedProducts); - } - - /** - * @param $productId - * @param array $productsAttributes - * @return bool - */ - private function isProductEnabled($productId, array $productsAttributes): bool - { - $status = $this->searchDataProvider->getSearchableAttribute('status'); - $allowedStatuses = $this->catalogProductStatus->getVisibleStatusIds(); - return isset($productsAttributes[$productId][$status->getId()]) && - in_array($productsAttributes[$productId][$status->getId()], $allowedStatuses); - } - - /** - * @param array $productsAttributes - * @param array $staticAttributes - */ - private function addStaticAttributes(array &$productsAttributes, array $staticAttributes): void - { - if (count($productsAttributes) == 0 || count($staticAttributes) == 0) { - return; - } - $attributeIds = array_flip($staticAttributes); - - $conn = $this->resource->getConnection(); - $select = $conn->select() - ->from($conn->getTableName('catalog_product_entity'), ['entity_id']) - ->columns($staticAttributes) - ->where('entity_id IN (?)', array_keys($productsAttributes)); - foreach ($conn->query($select) as $row) { - $productId = $row['entity_id']; - unset($row['entity_id']); - foreach ($row as $col => $val) { - $productsAttributes[$productId][$attributeIds[$col]] = $val; - } - } - } - - /** - * @param array $productIndex - * @param array $productData - * @param int $storeId - * @param array $additionalFields - * @return array - */ - private function prepareProductIndex( - array $productIndex, - array $productData, - int $storeId, - array $additionalFields - ): array { - // ensure product id is an integer - $productId = (int)$productData['entity_id']; - $typeId = $productData['type_id']; - - // first convert index to be based on attributes at top level, also converting values where necessary - $index = $this->searchDataProvider->prepareProductIndex($productIndex, $productData, $storeId); - - // map attribute ids to attribute codes, get values for options - $indexData = $this->productMapper->mapProduct( - $index, - $productId, - $storeId, - $typeId, - $additionalFields - ); - - // boolean attributes with value of "No" (0) get removed by above functions - replace them here - $this->populateBooleanAttributes($indexData); - - foreach ($indexData['variants'] as $variantId => $variantData) { - $this->variantIdParentMapping[$variantId] = $productId; - } - return $indexData; - } - - /** - * @param array $indexData - * @return void - */ - private function populateBooleanAttributes(array &$indexData): void - { - // all boolean attributes are of type "int" - $booleanAttributes = $this->attributeConfig->getBooleanAttributes(); - foreach ($booleanAttributes as $attribute) { - if (!isset($indexData['product'][$attribute['attribute']])) { - $indexData['product'][$attribute['attribute']] = '0'; - } - foreach ($indexData['variants'] as &$variantData) { - if (!isset($variantData[$attribute['attribute']])) { - $variantData[$attribute['attribute']] = '0'; - } - } - } - } - - /** - * @return array - */ - public function getVariantIdParentMapping() : array - { - return $this->variantIdParentMapping; - } -} diff --git a/Model/Indexer/Data/PriceFieldsProvider.php b/Model/Indexer/Data/PriceFieldsProvider.php deleted file mode 100644 index f7d4273..0000000 --- a/Model/Indexer/Data/PriceFieldsProvider.php +++ /dev/null @@ -1,115 +0,0 @@ -resourceConnection = $resourceConnection; - $this->storeManager = $storeManager; - $this->pricingAttributeConfig = $pricingAttributeConfig; - $this->dimensionCollectionFactory = $dimensionCollectionFactory; - $this->tableResolver = $tableResolver; - } - - /** - * @inheritDoc - * @throws NoSuchEntityException - * @throws \Zend_Db_Select_Exception - */ - public function getFields(array $productIds, $storeId): array - { - $result = []; - $useCustomerGroup = $this->pricingAttributeConfig->getUseCustomerGroup(); - $useRange = $this->pricingAttributeConfig->getUseRange(); - - $indexFields = [ - 'price' => 'regular_price', - 'final_price' => 'special_price' - ]; - if ($useRange) { - $indexFields = array_merge($indexFields, [ - 'min_price' => 'min_price', - 'max_price' => 'max_price' - ]); - } - - $productPriceData = $this->getPriceIndexData($productIds, $storeId, $indexFields); - foreach ($productPriceData as $productId => $customerGroupPriceData) { - $result[$productId] = []; - foreach ($customerGroupPriceData as $customerGroupId => $prices) { - foreach ($indexFields as $fieldName) { - $attributeName = $fieldName . ($useCustomerGroup ? "_$customerGroupId" : ""); - $result[$productId][$attributeName] = $prices[$fieldName]; - } - } - } - return $result; - } - - /** - * @param array $productIds - * @param $storeId - * @param $indexFields - * @return array - * @throws NoSuchEntityException - * @throws \Zend_Db_Select_Exception - */ - private function getPriceIndexData(array $productIds, $storeId, $indexFields): array - { - $connection = $this->resourceConnection->getConnection(); - $websiteId = $this->storeManager->getStore($storeId)->getWebsiteId(); - - $selects = []; - foreach ($this->dimensionCollectionFactory->create() as $dimensions) { - if (!isset($dimensions[WebsiteDimensionProvider::DIMENSION_NAME]) || - $websiteId === null || - $dimensions[WebsiteDimensionProvider::DIMENSION_NAME] === $websiteId) { - $select = $connection->select()->from( - $this->tableResolver->resolve('catalog_product_index_price', $dimensions), - ['entity_id', 'customer_group_id', 'website_id', 'price', 'final_price', 'min_price', 'max_price'] - ); - if ($productIds) { - $select->where('entity_id IN (?)', $productIds); - } - $selects[] = $select; - } - } - - $unionSelect = $connection->select()->union($selects); - $result = []; - foreach ($connection->fetchAll($unionSelect) as $row) { - $prices = []; - foreach ($indexFields as $columnName => $fieldName) { - $prices[$fieldName] = round((float)$row[$columnName], 2); - } - $result[$row['website_id']][$row['entity_id']][$row['customer_group_id']] = $prices; - } - - return $result[$websiteId] ?? []; - } -} diff --git a/Model/Indexer/Data/StockFieldsProvider.php b/Model/Indexer/Data/StockFieldsProvider.php deleted file mode 100644 index 098048e..0000000 --- a/Model/Indexer/Data/StockFieldsProvider.php +++ /dev/null @@ -1,59 +0,0 @@ -resourceConnection = $resourceConnection; - $this->stockAttributeConfig = $stockAttributeConfig; - } - - /** - * @inheritDoc - */ - public function getFields(array $productIds, $storeId): array - { - $result = []; - // only add stock information if enabled - if ($this->stockAttributeConfig->getSendStockCount() || $this->stockAttributeConfig->getSendStockStatus()) { - $connection = $this->resourceConnection->getConnection(); - $select = $connection->select()->from( - 'cataloginventory_stock_status', - ['product_id', 'qty', 'stock_status'] - ); - if ($productIds) { - $select->where('product_id IN (?)', $productIds); - } - - foreach ($connection->fetchAll($select) as $row) { - $stockInfo = []; - if ($this->stockAttributeConfig->getSendStockCount()) { - $stockInfo['stock_qty'] = $row['qty']; - } - if ($this->stockAttributeConfig->getSendStockStatus()) { - $stockInfo['stock_status'] = $row['stock_status']; - } - $result[$row['product_id']] = $stockInfo; - } - } - return $result; - } -} diff --git a/Model/Indexer/DataHandler.php b/Model/Indexer/DataHandler.php deleted file mode 100644 index a369921..0000000 --- a/Model/Indexer/DataHandler.php +++ /dev/null @@ -1,462 +0,0 @@ -resource = $resource; - $this->indexScopeResolver = $indexScopeResolver; - $this->batch = $batch; - $this->scopeResolver = $scopeResolver; - $this->indexStructure = $indexStructure; - $this->json = $json; - $this->dataProvider = $dataProvider; - $this->attributeConfig = $attributeConfig; - $this->documentPreProcessors = $documentPreProcessors; - $this->documentPostProcessors = $documentPostProcessors; - $this->batchSize = $batchSize; - } - - /** - * @inheritDoc - */ - public function saveIndex($dimensions, \Traversable $documents) - { - $scopeId = $this->getScopeId($dimensions); - $products = []; - $variants = []; - foreach ($this->batch->getItems($documents, self::BATCH_SIZE) as $documents) { - $this->processDocuments($documents, $scopeId); - foreach ($documents as $productId => $productData) { - $products[$productId] = $productData['product']; - foreach ($productData['variants'] as $variantId => $variantData) { - $variants[$variantId] = $variantData; - } - } - } - - $variantIdParentMapping = $this->dataProvider->getVariantIdParentMapping(); - // have total makeup of products and variants now - // first, insert into the working table - $this->insertProductData($dimensions, $products, $variants, $variantIdParentMapping); - // compare with current values and update records where needed - $this->applyProductChanges($dimensions); - } - - /** - * Final processing of attributes to ensure attributes are sent at correct level (product/variant), and that - * site variants are appended where needed. - * Also provide pre- / post-processors for custom code to hook into - * @param array $documents - * @param $scopeId - */ - public function processDocuments(array &$documents, $scopeId) : void - { - foreach ($this->documentPreProcessors as $preProcessor) { - $preProcessor->processDocuments($documents, $scopeId); - } - if (!empty($documents)) { - // if using variants, need to ensure each product has a variant - if ($this->attributeConfig->getUseVariantProducts()) { - $this->processVariants($documents); - } else { - $this->processProducts($documents); - } - } - foreach ($this->documentPostProcessors as $postProcessor) { - $postProcessor->processDocuments($documents, $scopeId); - } - } - - /** - * Handles the processing of variant-level attributes for products - * @param array $documents passed by reference - * @return void - */ - private function processVariants(array &$documents) - { - $productAttributeCodes = []; - foreach ($this->attributeConfig->getProductAttributeCodes(true) as $code) { - $productAttributeCodes[$code] = true; - } - foreach ($documents as $productId => &$data) { - // copy product data to variant - if (empty($data['variants'])) { - $data['variants'] =[ - $productId => $data['product'] - ]; - } - - // remove any variant-level attributes from parent product, ensuring it is set on each variant - foreach ($data['product'] as $attributeCode => $productData) { - if (in_array($attributeCode, $this->attributeConfig->getVariantAttributeCodes(true))) { - foreach ($data['variants'] as &$variantData) { - $variantData[$attributeCode] = $variantData[$attributeCode] ?? $productData; - } - if (!isset($productAttributeCodes[$attributeCode])) { - unset($data['product'][$attributeCode]); - } - continue; // continue with the next attribute - } - // check pricing attributes - // need to use strpos as we can have customer group pricing - foreach ($this->variantPriceAttributes as $priceAttributePrefix) { - if (strpos($attributeCode, $priceAttributePrefix) === 0) { - foreach ($data['variants'] as &$variantData) { - $variantData[$attributeCode] = $variantData[$attributeCode] ?? $productData; - } - unset($data['product'][$attributeCode]); - break; // skip the rest of the pricing attribute loop - } - } - } - - // remove product-level attributes from variants - foreach ($data['variants'] as &$variantData) { - foreach ($variantData as $attributeCode => $attributeValue) { - if (!in_array($attributeCode, $this->attributeConfig->getVariantAttributeCodes(true))) { - unset($variantData[$attributeCode]); - } - } - } - } - } - - /** - * Collates variant-level attributes into the parent product - * @param array $documents passed by reference - * @return void - */ - private function processProducts(array &$documents) - { - // need to collate variant level attributes at product level - // keep them at variant level also - variant data won't be sent, but can be used to trigger resending - // of parent data - foreach ($documents as &$data) { - foreach ($this->attributeConfig->getVariantAttributeCodes(true) as $variantAttributeCode) { - $this->processProductVariantAttribute($data, $variantAttributeCode); - } - } - } - - /** - * Collates the variant-level values for a single attribute - * @param array $data passed by reference - * @param $variantAttributeCode - * @return void - */ - private function processProductVariantAttribute(array &$data, $variantAttributeCode) - { - // convert product attribute to an array if it's not already - if (isset($data['product'][$variantAttributeCode]) && - !is_array($data['product'][$variantAttributeCode])) { - $data['product'][$variantAttributeCode] = [$data['product'][$variantAttributeCode]]; - } - $valueArray = []; - foreach ($data['variants'] as $variantData) { - if (isset($variantData[$variantAttributeCode])) { - $value = $variantData[$variantAttributeCode]; - $valueArray[] = is_array($value) ? $value : [$value]; - } - } - $valueArray = array_merge([], ...$valueArray); - - // if there are variant values to include, ensure product value is set - if (!empty($valueArray)) { - $data['product'][$variantAttributeCode] = $data['product'][$variantAttributeCode] ?? []; - $data['product'][$variantAttributeCode] = array_merge( - $data['product'][$variantAttributeCode], - $valueArray - ); - } - } - - /** - * @param $dimensions - * @param array $products - * @param array $variants - * @param array $variantIdParentMapping - * @return void - */ - private function insertProductData( - $dimensions, - array $products, - array $variants, - array $variantIdParentMapping - ) : void { - $productRows = []; - foreach ($products as $productId => $attributeData) { - $productRows[] = [ - 'product_type' => self::TYPE_PRODUCT, - 'product_id' => $productId, - 'attribute_data' => $this->json->serialize($this->sortArray($attributeData)) - ]; - } - - $variantRows = []; - foreach ($variants as $variantId => $attributeData) { - $variantRows[] = [ - 'product_type' => self::TYPE_VARIANT, - 'product_id' => $variantId, - // dummy variants have themselves as parents - 'parent_id' => $variantIdParentMapping[$variantId] ?? $variantId, - 'attribute_data' => $this->json->serialize($this->sortArray($attributeData)) - ]; - } - foreach (array_chunk($productRows, $this->batchSize) as $batchRows) { - $this->resource->getConnection() - ->insertOnDuplicate($this->getTableName($dimensions), $batchRows, ['attribute_data']); - } - - foreach (array_chunk($variantRows, $this->batchSize) as $batchRows) { - $this->resource->getConnection() - ->insertOnDuplicate($this->getTableName($dimensions), $batchRows, ['parent_id', 'attribute_data']); - } - } - - /** - * Insert/update records in main index table from store-level table - * Ensures correct deltas are sent to Fredhopper - * @param $dimensions - */ - private function applyProductChanges($dimensions) : void - { - $connection = $this->resource->getConnection(); - $indexTableName = self::INDEX_TABLE_NAME; - $scopeTableName = $this->getTableName($dimensions); - $storeId = $this->getScopeId($dimensions); - - // insert any new records and mark as "add" - $insertSelect = $connection->select(); - $insertSelect->from( - ['scope_table' => $scopeTableName], - ['product_type', 'product_id', 'parent_id', 'attribute_data'] - ); - $insertSelect->columns( - [ - 'operation_type' => new \Zend_Db_Expr($connection->quote(self::OPERATION_TYPE_ADD)), - 'store_id' => new \Zend_Db_Expr($storeId) - ] - ); - $insertQuery = $connection->insertFromSelect( - $insertSelect, - $indexTableName, - ['product_type', 'product_id', 'parent_id', 'attribute_data', 'operation_type', 'store_id'], - AdapterInterface::INSERT_IGNORE // ignore mode so only records that do not exist will be inserted - ); - $connection->query($insertQuery); - - // check for deleted records and mark as "delete" - $deleteWhereClause = "store_id = $storeId AND NOT EXISTS (SELECT 1 from $scopeTableName scope_table " . - " WHERE scope_table.product_id = ". $indexTableName . ".product_id " . - " AND scope_table.product_type = ". $indexTableName . ".product_type)"; - $connection->update( - $indexTableName, - ['operation_type' => self::OPERATION_TYPE_DELETE], - $deleteWhereClause - ); - - // find records to be updated - where attribute_data or parent_id has changed - $updateSubSelect = $connection->select(); - $updateSubSelect->from( - false, - ['operation_type' => $connection->quote(self::OPERATION_TYPE_UPDATE)] // used for setting value - ); - $updateSubSelect->join( - ['scope_table' => $scopeTableName], - 'main_table.product_id = scope_table.product_id AND main_table.product_type = scope_table.product_type', - ['attribute_data', 'parent_id'] - ); - $updateSubSelect->where('main_table.store_id = ?', $storeId); - $updateSubSelect->where('(main_table.attribute_data <> scope_table.attribute_data - OR NOT main_table.parent_id <=> scope_table.parent_id)'); - - $updateQuery = $connection->updateFromSelect( - $updateSubSelect, - ['main_table' => $indexTableName] - ); - $connection->query($updateQuery); - - // Restore incorrectly deleted products by clearing operation_type - // Find records that are no longer missing from the scope table but are marked for deletion - $restoreSubSelect = $connection->select(); - $restoreSubSelect->from( - false, - ['operation_type' => 'NULL'] // used for setting value - ); - $restoreSubSelect->join( - ['scope_table' => $scopeTableName], - 'main_table.product_id = scope_table.product_id AND main_table.product_type = scope_table.product_type', - [] - ); - $restoreSubSelect->where('main_table.store_id = ?', $storeId); - $restoreSubSelect->where('main_table.operation_type = ?', self::OPERATION_TYPE_DELETE); - - $restoreQuery = $connection->updateFromSelect( - $restoreSubSelect, - ['main_table' => $indexTableName] - ); - $connection->query($restoreQuery); - } - - /** - * Function used to "reset" main index table after performing an incremental update - * @return bool - */ - public function resetIndexAfterExport(): bool - { - $connection = $this->resource->getConnection(); - $indexTableName = self::INDEX_TABLE_NAME; - // first, remove any records marked for deletion - $connection->delete($indexTableName, ['operation_type = ?' => self::OPERATION_TYPE_DELETE]); - - // where clause is not technically needed, but operation_type column is indexed, so this should reduce the - // amount of work and maintain/improve performance - $connection->update( - $indexTableName, - ['operation_type' => new \Zend_Db_Expr('NULL')], - 'operation_type IS NOT NULL' - ); - return true; - } - - /** - * @inheritDoc - */ - public function deleteIndex($dimensions, \Traversable $documents) - { - foreach ($this->batch->getItems($documents, $this->batchSize) as $batchDocuments) { - $this->resource->getConnection() - ->delete($this->getTableName($dimensions), ['product_id in (?)' => $batchDocuments]); - $this->resource->getConnection() - ->delete($this->getTableName($dimensions), ['parent_id in (?)' => $batchDocuments]); - } - } - - /** - * @inheritDoc - */ - public function cleanIndex($dimensions) - { - $this->indexStructure->delete(self::INDEX_TABLE_NAME, $dimensions); - $this->indexStructure->create(self::INDEX_TABLE_NAME, [], $dimensions); - } - - /** - * @inheritDoc - */ - public function isAvailable($dimensions = []): bool - { - return $this->resource->getConnection()->isTableExists($this->getTableName($dimensions)); - } - - /** - * @param Dimension[] $dimensions - * @return int - */ - private function getScopeId(array $dimensions) : int - { - $dimension = current($dimensions); - return (int)$this->scopeResolver->getScope($dimension->getValue())->getId(); - } - - /** - * @param Dimension[] $dimensions - * @return string - */ - private function getTableName(array $dimensions) : string - { - return $this->indexScopeResolver->resolve(self::INDEX_TABLE_NAME, $dimensions); - } - - /** - * Function to recursively sort an array by key (or value if keys are numeric) for ease of comparison by string - * @param array $array - * @return array - */ - private function sortArray(array $array) : array - { - foreach ($array as $key => $value) { - if (is_array($value)) { - $array[$key] = $this->sortArray($value); - } - } - reset($array); - if (is_numeric(key($array))) { - asort($array); - $array = array_values($array); // reorder numeric keys - } else { - ksort($array); - } - return $array; - } -} diff --git a/Model/IndexerInfo.php b/Model/IndexerInfo.php deleted file mode 100644 index 06041d5..0000000 --- a/Model/IndexerInfo.php +++ /dev/null @@ -1,165 +0,0 @@ -resourceConnection = $resourceConnection; - $this->indexerCollectionFactory = $indexerCollectionFactory; - } - - /** - * @param IndexerInterface $indexer - * @return string - * @see \Magento\Indexer\Console\Command\IndexerStatusCommand::getStatus - */ - public function getIndexerStatus(IndexerInterface $indexer): string - { - $status = 'unknown'; - switch ($indexer->getStatus()) { - case StateInterface::STATUS_VALID: - $status = 'Ready'; - break; - case StateInterface::STATUS_INVALID: - $status = 'Reindex required'; - break; - case StateInterface::STATUS_WORKING: - $status = 'Processing'; - break; - } - return $status; - } - - /** - * @param ViewInterface $view - * @return int - */ - public function getPendingCount(ViewInterface $view): int - { - $changelog = $view->getChangelog(); - try { - $currentVersionId = $changelog->getVersion(); - } catch (\Exception $e) { - return 0; - } - - $state = $view->getState(); - return count($changelog->getList($state->getVersionId(), $currentVersionId)); - } - - /** - * @return array - */ - public function getIndexState(): array - { - $rows = []; - $indexers = $this->indexerCollectionFactory->create()->getItems(); - - /** @var DependencyDecorator $indexer */ - foreach ($indexers as $indexer) { - /** @var View $view */ - $view = $indexer->getView(); - $pending = $this->getPendingCount($view); - $rows[] = [ - 'id' => $indexer->getIndexerId(), - 'title' => $indexer->getTitle(), - 'status' => $this->getIndexerStatus($indexer), - 'schedule_status' => $view->getState()->getStatus(), - 'schedule_backlog' => $pending, - 'schedule_updated' => $view->getState()->getUpdated(), - ]; - } - - // Ensure same order as bin/magento indexer:status - array_multisort(array_column($rows, 'title'), SORT_ASC, $rows); - - return $rows; - } - - /** - * @return array - */ - public function getFredhopperIndexState(): array - { - $conn = $this->resourceConnection->getConnection(); - - $ops = [ - DataHandler::OPERATION_TYPE_ADD => 'add', - DataHandler::OPERATION_TYPE_DELETE => 'delete', - DataHandler::OPERATION_TYPE_UPDATE => 'update', - DataHandler::OPERATION_TYPE_REPLACE => 'replace', - ]; - $baseTable = DataHandler::INDEX_TABLE_NAME; - $result = []; - - $select = $conn->select(); - $select->from($baseTable); - $select->reset(Zend_Db_Select::COLUMNS); - $select->columns(['store' => 'store_id', 'type' => 'product_type']); - foreach ($ops as $op => $label) { - // N.B. both values are defined in code; safe SQL-injection - $select->columns([$label => "SUM(operation_type = '$op')"]); - } - $select->group(['store_id', 'product_type']); - try { - $rows = $conn->fetchAll($select); - } catch (\Throwable $ex) { - return [['Error' => 'Error']]; - } - - $storeIds = []; - foreach ($rows as $row) { - $key = $row['store'] . '-' . $row['type']; - $storeId = (int)$row['store']; - if ($storeId > 0 && !isset($storeIds[$storeId])) { - $storeIds[$storeId] = true; - } - $resultRow = $row; - $resultRow['scope'] = 'N/A'; - $result[$key] = $resultRow; - } - - foreach ($storeIds as $storeId => $ignore) { - $select = $conn->select(); - $select->from($baseTable . '_scope' . $storeId); - $select->reset(Zend_Db_Select::COLUMNS); - $select->columns(['product_type', 'total_count' => 'COUNT(*)']); - $select->group('product_type'); - try { - $rows = $conn->fetchAll($select); - } catch (\Throwable $ex) { - continue; - } - foreach ($rows as $row) { - $key = $storeId . '-' . $row['product_type']; - $result[$key]['scope'] = $row['total_count']; - } - } - - // No longer need store_id - product_type key - return array_values($result); - } -} diff --git a/Model/ResourceModel/Engine.php b/Model/ResourceModel/Engine.php deleted file mode 100644 index cba33fd..0000000 --- a/Model/ResourceModel/Engine.php +++ /dev/null @@ -1,60 +0,0 @@ -visibility = $visibility; - } - - /** - * @inheritDoc - */ - public function getAllowedVisibility(): array - { - return $this->visibility->getVisibleInSiteIds(); - } - - /** - * @inheritDoc - */ - public function allowAdvancedIndex(): bool - { - return false; - } - - /** - * @inheritDoc - */ - public function processAttributeValue($attribute, $value) - { - return $value; - } - - /** - * @inheritDoc - */ - public function prepareEntityIndex($index, $separator = ' '): array - { - return $index; - } - - /** - * @return bool - */ - public function isAvailable(): bool - { - return true; - } -} diff --git a/Model/Search/Adapter/DummyResponseFactory.php b/Model/Search/Adapter/DummyResponseFactory.php deleted file mode 100644 index e70e166..0000000 --- a/Model/Search/Adapter/DummyResponseFactory.php +++ /dev/null @@ -1,37 +0,0 @@ -objectManager = $objectManager; - } - - public function create() - { - $aggregation = $this->objectManager->create( - Aggregation::class, - ['buckets' => []] - ); - return $this->objectManager->create( - QueryResponse::class, - [ - 'documents' => [], - 'aggregations' => $aggregation, - 'total' => 0 - ] - ); - } -} diff --git a/Model/Search/DummySearchAdapter.php b/Model/Search/DummySearchAdapter.php deleted file mode 100644 index 330da64..0000000 --- a/Model/Search/DummySearchAdapter.php +++ /dev/null @@ -1,30 +0,0 @@ -responseFactory = $responseFactory; - } - - /** - * @inheritDoc - */ - public function query(RequestInterface $request) - { - return $this->responseFactory->create(); - } -} diff --git a/Plugin/Model/Indexer/Fulltext/Action/DataProviderPlugin.php b/Plugin/Model/Indexer/Fulltext/Action/DataProviderPlugin.php deleted file mode 100644 index da620e9..0000000 --- a/Plugin/Model/Indexer/Fulltext/Action/DataProviderPlugin.php +++ /dev/null @@ -1,28 +0,0 @@ -fredhopperDataProvider = $fredhopperDataProvider; - } - - /** - * @param Full $subject - * @param callable $proceed - * @param $storeId - * @param $productIds - * @return \Generator - */ - public function aroundRebuildStoreIndex( - Full $subject, - callable $proceed, - $storeId, - $productIds = null - ): \Generator { - return $this->fredhopperDataProvider->rebuildStoreIndex($storeId, $productIds); - } -} diff --git a/README.md b/README.md index 46dc0db..c18da11 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,33 @@ # Aligent Fredhopper Indexer ## Overview -This module is intended to replace the core Magento 2 CatalogSearch indexer (`catalogsearch_fulltext`) with one that prepares product data for export to Fredhopper. +This package provides indexing of product data, as well as the subsequent exporting of that data to Fredhopper, a merchandising platform. + +## Installation +This package provides 3 related modules, and can be installed via composer: +```shell +composer require aligent/magento2-fredhopper-indexer +bin/magento module:enable Aligent_FredhopperIndexer Aligent_FredhopperExport Aligent_FredhopperCommon +bin/magento setup:upgrade +``` ## Architecture - New database table, `fredhopper_product_data_index` - Stores attribute data for both products and variants in JSON format -- New search engine, `fredhopper`, to be used in place of Elasticsearch (or other). - - Note that search capability is not part of this module, and should be handled by another module (e.g. Aligent_DataProxy). This module is only concerned with the flow of data from Magento to Fredhopper. -- 3 new cron jobs for exporting data to Fredhopper. All three jobs use the JSON data format. - - `fredhopper_full_export` - Exports the current attribute data for all products/variants. This will also export meta information about attributes. - - `fredhopper_incremental_export` - Exports product/variants that have changed since the last run. - - This job is added to the `index` cron group, so that it will not be affected by ongoing indexing processes. - - `fredhopper_suggest_export` - Exports data to be used in 'instant search' functionality. + - Associated new database table, `fredhopper_changelog` + - Keeps track of which products have been added, updated or deleted from the index. This allows for incremental exports. +- New `fredhopper` indexer, responsible for indexing the required product data. +- New `Export` entity, which allows for tracking of exports to Fredhopper. Information such as the number of products added/updated/deleted, as well as status is maintained. +- 8 new cron jobs for generating and maintaining exports. + - `fredhopper_full_export` - Generates a full export of the current attribute data for all products/variants. This will also include meta information about attributes. + - `fredhopper_incremental_export` - Generates an export of product/variants that have changed since the last data set was exported. + - `fredhopper_suggest_export` - Generates an export for data to be used in 'instant search' functionality. + - `fredhopper_upload_export` - Uploads any waiting export to Fredhopper + - `fredhopper_trigger_export` - Triggers an uploaded export to be loaded into Fredhopper + - `fredhopper_invalidate_exports` - Marks any exports that have not been uploaded, and been superseded by another export as invalid + - `fredhopper_update_data_status` - Checks and updates the status of exports based on information returned from Fredhopper. + - `fredhopper_clean_exports` - Removes exports older than a configured age (default 3 days) ## Configuration This module provides a number of configurable settings for controlling which data is sent to Fredhopper and in what format: @@ -24,16 +38,12 @@ This module provides a number of configurable settings for controlling which dat - Only attributes meeting certain criteria (e.g. searchable, visible in front-end, etc.) will be able to be selected. - Variant-Level Attributes - Which attributes are either sent at the variant level (if using variant products) or aggregated to the parent product. -- Pricing Attributes - - Send pricing attributes per site / per customer group? Include min/max pricing? -- Stock Attributes - - Send in-stock indicator and/or stock count? -- Product Age Attributes - - Send attributes indicating if the product is new and/or the number of days online? +- Attribute name mapping + - Maps Magento attribute codes to Fredhopper attribute ids - Allowed/Disallowed Search Terms - Force or prevent certain search results in instant search functionality. - Cron Schedules - - Change schedule of each cron job if desired. + - Change schedule of each cron job that generates an export if desired. ## Customisation There are a number of customisation points in the module, allowing for data to be added/modified. @@ -44,21 +54,18 @@ There are a number of customisation points in the module, allowing for data to b Aligent\FredhopperIndexer\Model\Indexer\Data\CategoryFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\PriceFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\StockFieldsProvider Aligent\FredhopperIndexer\Model\Indexer\Data\ImageFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\AgeFieldsProvider ``` - Document Pre/Post-Processors - - `\Aligent\FredhopperIndexer\Model\Indexer\DataHandler` class has 2 arguments, `documentPreProcessors` and `documentPostProcessors`, which handle an array of classes implementing `\Aligent\\FredhopperIndexer\Api\Indexer\Data\DocumentProcessorInterface` + - `\Aligent\FredhopperIndexer\Model\DataHandler` class has 2 arguments, `documentPreProcessors` and `documentPostProcessors`, which handle an array of classes implementing `\Aligent\FredhopperIndexer\Api\Indexer\Data\DocumentProcessorInterface` - These will be run before and after "processing" of documents as a whole (i.e. after all attribute information has been added). As such, this is a good place to handle any custom aggregation or functionality relating to the product as a whole (as opposed to individual attributes). - Meta - - Custom attributes added via code will need to be added to the meta information that is sent to Fredhopper. This is done by adding to the `customAttributeData` argument of the `\Aligent\FredhopperIndexer\Helper\CustomAttributeConfig` class: + - Custom attributes added via code will need to be added to the meta information that is sent to Fredhopper. This is done by adding to the `customAttributeData` argument of the `\Aligent\FredhopperCommon\Model\Config\CustomAttributeConfig` class: ``` - + @@ -71,15 +78,41 @@ There are a number of customisation points in the module, allowing for data to b ``` - Custom Suggest Data - - The `\Aligent\FredhopperIndexer\Model\Export\SuggestExporter` class provides the `fileGenerators` argument by which custom data feeds can be added to the "suggest" export. - - Each class added to the array must implement `\Aligent\FredhopperIndexer\Api\Export\FileGeneratorInterface` + - The `\Aligent\FredhopperExport\Model\GenerateSuggestExport` class provides the `fileGenerators` argument by which custom data feeds can be added to the "suggest" export. + - Each class added to the array must implement `\Aligent\FredhopperExport\Api\FileGeneratorInterface` ``` - + + + true + + + + + false + + + - Aligent\FredhopperIndexer\Model\Export\Data\BlacklistFileGenerator - Aligent\FredhopperIndexer\Model\Export\Data\WhitelistFileGenerator + BlacklistFileGenerator + WhitelistFileGenerator ``` +- Export Validation + - The `\Aligent\FredhopperExport\Model\UploadExport` class provides the `validators` argument by which any pre-export validation can be added. + - Each class added to the array must implement `\Aligent\FredhopperExport\Api\PreExportValidatorInterface` +``` + + + + Aligent\FredhopperExport\Model\Validator\DeletedProductsValidator + Aligent\FredhopperExport\Model\Validator\MinimumProductsValidator + + + +``` + +## Administration +A view of exports is provided in the Adobe Commerce admin area. This view lists all generated exports, including information such as the number of added, updated and deleted products, as well as the export's current status in the system. diff --git a/composer.json b/composer.json index e062202..e75ab80 100644 --- a/composer.json +++ b/composer.json @@ -2,13 +2,18 @@ "name": "aligent/magento2-fredhopper-indexer", "description": "Index and push data from Magento to Fredhopper", "require": { - "php": ">=7.4", + "php": "^8.3.0", "aligent/magento2-category-selector": "*", + "magento/magento-composer-installer": "*", + "magento/module-advanced-search": "*", + "magento/module-catalog": "*", "magento/module-catalog-search": "*", - "magento/magento-composer-installer": "*" - }, - "suggest": { - "aligent/dataproxy": "Pull and process data from Fredhopper" + "magento/module-config": "*", + "magento/module-configurable-product": "*", + "magento/module-eav": "*", + "magento/module-elasticsearch": "*", + "magento/module-store": "*", + "ext-zip": "*" }, "type": "magento2-module", "license": [ @@ -16,10 +21,14 @@ ], "autoload": { "files": [ - "registration.php" + "src/common/registration.php", + "src/export/registration.php", + "src/index/registration.php" ], "psr-4": { - "Aligent\\FredhopperIndexer\\": "" + "Aligent\\FredhopperIndexer\\": "src\\index", + "Aligent\\FredhopperExport\\": "src\\export", + "Aligent\\FredhopperCommon\\": "src\\common" } } } diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml deleted file mode 100644 index 324a125..0000000 --- a/etc/adminhtml/system.xml +++ /dev/null @@ -1,208 +0,0 @@ - - -
- - aligent - Aligent_FredhopperIndexer::manage - - - - - - - - Magento\Config\Model\Config\Backend\Encrypted - - - - - - - - - - - - - - - Magento\Config\Model\Config\Source\Yesno - - - - - 1 - - - - - Store to be used for attributes that are not site-specific - Magento\Config\Model\Config\Source\Store - - - - Stores to be excluded when generating export data - Magento\Config\Model\Config\Source\Store - - - - Subcategories are sent to Fredhopper - Aligent\CategorySelector\Model\Config\Source\Category - - - - Enable debug logging for export process - Magento\Config\Model\Config\Source\Yesno - - - - - - - - - - Applies to both full and incremental exports - - - - - - - - - - Comma-separated list - - - - - - - If enabled, variant products will be sent to Fredhopper - Magento\Config\Model\Config\Source\Yesno - - - - Attributes sent to Fredhopper at product level - Aligent\FredhopperIndexer\Block\Adminhtml\Form\Field\AttributeConfig - Magento\Config\Model\Config\Backend\Serialized\ArraySerialized - - - - Attributes sent to Fredhopper at product level - Aligent\FredhopperIndexer\Block\Adminhtml\Form\Field\AttributeConfig - Magento\Config\Model\Config\Backend\Serialized\ArraySerialized - - - - - - - Send separate price attributes per customer group - Magento\Config\Model\Config\Source\Yesno - - - - Send separate price attributes per site variant - Magento\Config\Model\Config\Source\Yesno - - 1 - - - - - Send min/max price attributes at product level (also takes customer group and site variant settings into account) - Magento\Config\Model\Config\Source\Yesno - - - - - - - Send boolean indicator for whether the product is in stock or not - Magento\Config\Model\Config\Source\Yesno - - - - Send stock position of products - Magento\Config\Model\Config\Source\Yesno - - - - Send separate stock attributes per site variant - Magento\Config\Model\Config\Source\Yesno - - 1 - - - - - - - - Send boolean indicator for whether the product is new or not - Magento\Config\Model\Config\Source\Yesno - - - - Send the age of the product in days - Magento\Config\Model\Config\Source\Yesno - - - - Aligent\FredhopperIndexer\Data\CreatedAtOptionSource - - - - Send separate attributes per site variant - Magento\Config\Model\Config\Source\Yesno - - 1 - - - - - - - - Send separate image attributes per site variant - Magento\Config\Model\Config\Source\Yesno - - 1 - - - - - - - - Used to remove suggestions which are found in the historic search data. - Aligent\FredhopperIndexer\Block\Adminhtml\Form\Field\SearchTerm - Magento\Config\Model\Config\Backend\Serialized\ArraySerialized - - - - Used to add any additional suggestions which are not found in the historic search data. - Aligent\FredhopperIndexer\Block\Adminhtml\Form\Field\SearchTerm - Magento\Config\Model\Config\Backend\Serialized\ArraySerialized - - - - - - - Cron expression for incremental product update schedule - - - - Cron expression for full product update schedule - - - - Cron expression for suggest update schedule - - -
-
-
diff --git a/etc/crontab.xml b/etc/crontab.xml deleted file mode 100644 index 904ce53..0000000 --- a/etc/crontab.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - fredhopper_indexer/cron/incremental_schedule - - - - - - - - fredhopper_indexer/cron/full_schedule - - - fredhopper_indexer/cron/suggest_schedule - - - diff --git a/etc/di.xml b/etc/di.xml deleted file mode 100644 index 94ce0aa..0000000 --- a/etc/di.xml +++ /dev/null @@ -1,233 +0,0 @@ - - - - - - - Aligent\FredhopperIndexer\Console\Command\ValidateProductExport - Aligent\FredhopperIndexer\Console\Command\TouchProduct - Aligent\FredhopperIndexer\Console\Command\FullExport - Aligent\FredhopperIndexer\Console\Command\SpecificProductExport - - - - - - - - Aligent\FredhopperIndexer\Model\Export\Validator\DeletedProductsValidator - - - - - - - - FredhopperImmediateExporter - - - - - Aligent\FredhopperIndexer\Model\Export\Data\ImmediateProducts - false - - - - - - - Aligent\FredhopperIndexer\Model\Indexer\DataHandler - - - - - - - - Aligent\FredhopperIndexer\Model\Indexer\StructureHandler - - - - - - - - Aligent\FredhopperIndexer\Model\ResourceModel\Engine - - - - - - - - Aligent\FredhopperIndexer\Model\Search\DummySearchAdapter - - - - - - - - fredhopper - - - - - - - - Fredhopper - - - - - - - - Magento\CatalogSearch\Model\ResourceModel\Advanced\CollectionFactory - - - - - - - - - - thumbnail - category_page_list - - - image - product_page_main_image - - - - - - - - - Aligent\FredhopperIndexer\Model\Indexer\Data\CategoryFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\PriceFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\StockFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\ImageFieldsProvider - Aligent\FredhopperIndexer\Model\Indexer\Data\AgeFieldsProvider - - - - - - additionalFieldsProviderForFredhopper - - - - - - - Aligent\FredhopperIndexer\Model\Export\Data\BlacklistFileGenerator - Aligent\FredhopperIndexer\Model\Export\Data\WhitelistFileGenerator - - - - - - - - - - - - - - - Magento\CatalogSearch\Model\Layer\Search\ItemCollectionProvider - - - - - Magento\CatalogSearch\Model\Layer\Search\ItemCollectionProvider - - - - - FredhopperCategoryContext - - - - - FredhopperSearchContext - - - - - - /var/log/fredhopper_export.log - - - - - FredhopperExport - - FredhopperExportLoggerHandler - - - - - - FredhopperExportLogger - - - - - FredhopperExportLogger - - - - - FredhopperExportLogger - - - - - Magento\Framework\Filesystem\Driver\File - FredhopperExportLogger - - - - - - Magento\Framework\Filesystem\Driver\File - - - - - - - Aligent\FredhopperIndexer\Model\Export\IncrementalExporter - - Aligent\FredhopperIndexer\Model\Export\Validator\DeletedProductsValidator - - - - - - Aligent\FredhopperIndexer\Model\Export\FullExporter - - Aligent\FredhopperIndexer\Model\Export\Validator\DeletedProductsValidator - - - - - - Aligent\FredhopperIndexer\Model\Export\SuggestExporter - - - - - Aligent\FredhopperIndexer\Model\Export\IncrementalExporter - - - diff --git a/etc/email_templates.xml b/etc/email_templates.xml deleted file mode 100644 index 95d4e4c..0000000 --- a/etc/email_templates.xml +++ /dev/null @@ -1,6 +0,0 @@ - - -