* @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) */ declare(strict_types=1); namespace PrestaShop\PrestaShop\Adapter\Product\Update; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; use Language; use PrestaShop\PrestaShop\Adapter\Product\Combination\Repository\CombinationRepository; use PrestaShop\PrestaShop\Adapter\Product\Combination\Update\CombinationStockProperties; use PrestaShop\PrestaShop\Adapter\Product\Combination\Update\CombinationStockUpdater; use PrestaShop\PrestaShop\Adapter\Product\Image\ProductImagePathFactory; use PrestaShop\PrestaShop\Adapter\Product\Image\Repository\ProductImageRepository; use PrestaShop\PrestaShop\Adapter\Product\Repository\ProductRepository; use PrestaShop\PrestaShop\Adapter\Product\Repository\ProductSupplierRepository; use PrestaShop\PrestaShop\Adapter\Product\SpecificPrice\Repository\SpecificPriceRepository; use PrestaShop\PrestaShop\Adapter\Product\Stock\Repository\StockAvailableRepository; use PrestaShop\PrestaShop\Adapter\Product\Stock\Update\ProductStockProperties; use PrestaShop\PrestaShop\Adapter\Product\Stock\Update\ProductStockUpdater; use PrestaShop\PrestaShop\Core\Domain\Product\Combination\ValueObject\CombinationId; use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotDuplicateProductException; use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotUpdateProductException; use PrestaShop\PrestaShop\Core\Domain\Product\Image\ValueObject\ImageId; use PrestaShop\PrestaShop\Core\Domain\Product\ProductSettings; use PrestaShop\PrestaShop\Core\Domain\Product\Stock\Exception\StockAvailableNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Product\Stock\ValueObject\OutOfStockType; use PrestaShop\PrestaShop\Core\Domain\Product\Stock\ValueObject\StockModification; use PrestaShop\PrestaShop\Core\Domain\Product\Supplier\ValueObject\ProductSupplierId; use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductId; use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType; use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopId; use PrestaShop\PrestaShop\Core\Exception\CoreException; use PrestaShop\PrestaShop\Core\Exception\InvalidArgumentException; use PrestaShop\PrestaShop\Core\Hook\HookDispatcherInterface; use PrestaShop\PrestaShop\Core\Repository\AbstractMultiShopObjectModelRepository; use PrestaShop\PrestaShop\Core\Util\DateTime\DateTime; use PrestaShop\PrestaShop\Core\Util\String\StringModifierInterface; use PrestaShopException; use Product; use ProductDownload as VirtualProductFile; use Symfony\Component\Filesystem\Filesystem; use Symfony\Contracts\Translation\TranslatorInterface; /** * Duplicates product */ class ProductDuplicator extends AbstractMultiShopObjectModelRepository { /** * @var ProductRepository */ private $productRepository; /** * @var HookDispatcherInterface */ private $hookDispatcher; /** * @var TranslatorInterface */ private $translator; /** * @var StringModifierInterface */ private $stringModifier; /** * @var Connection */ private $connection; /** * @var string */ private $dbPrefix; /** * @var CombinationRepository */ private $combinationRepository; /** * @var ProductSupplierRepository */ private $productSupplierRepository; /** * @var SpecificPriceRepository */ private $specificPriceRepository; /** * @var StockAvailableRepository */ private $stockAvailableRepository; /** * @var ProductStockUpdater */ private $productStockUpdater; /** * @var CombinationStockUpdater */ private $combinationStockUpdater; /** * @var ProductImageRepository */ private $productImageRepository; /** * @var ProductImagePathFactory */ private $productImageSystemPathFactory; public function __construct( ProductRepository $productRepository, HookDispatcherInterface $hookDispatcher, TranslatorInterface $translator, StringModifierInterface $stringModifier, Connection $connection, string $dbPrefix, CombinationRepository $combinationRepository, ProductSupplierRepository $productSupplierRepository, SpecificPriceRepository $specificPriceRepository, StockAvailableRepository $stockAvailableRepository, ProductStockUpdater $productStockUpdater, CombinationStockUpdater $combinationStockUpdater, ProductImageRepository $productImageRepository, ProductImagePathFactory $productImageSystemPathFactory ) { $this->productRepository = $productRepository; $this->hookDispatcher = $hookDispatcher; $this->translator = $translator; $this->stringModifier = $stringModifier; $this->connection = $connection; $this->dbPrefix = $dbPrefix; $this->combinationRepository = $combinationRepository; $this->productSupplierRepository = $productSupplierRepository; $this->specificPriceRepository = $specificPriceRepository; $this->stockAvailableRepository = $stockAvailableRepository; $this->productStockUpdater = $productStockUpdater; $this->combinationStockUpdater = $combinationStockUpdater; $this->productImageRepository = $productImageRepository; $this->productImageSystemPathFactory = $productImageSystemPathFactory; } /** * @param ProductId $productId * @param ShopConstraint $shopConstraint * * @return ProductId new product id * * @throws CannotDuplicateProductException * @throws CannotUpdateProductException * @throws CoreException */ public function duplicate(ProductId $productId, ShopConstraint $shopConstraint): ProductId { //@todo: add database transaction. After/if PR #21740 gets merged $oldProductId = $productId->getValue(); $this->hookDispatcher->dispatchWithParameters( 'actionAdminDuplicateBefore', ['id_product' => $oldProductId] ); $newProduct = $this->duplicateProduct($productId, $shopConstraint); $newProductId = (int) $newProduct->id; $this->duplicateRelations($oldProductId, $newProductId, $shopConstraint, $newProduct->getProductType()); if ($newProduct->hasAttributes()) { $this->updateDefaultAttribute($newProductId, $oldProductId); } $this->hookDispatcher->dispatchWithParameters( 'actionProductAdd', ['id_product_old' => $oldProductId, 'id_product' => $newProductId, 'product' => $newProduct] ); $this->hookDispatcher->dispatchWithParameters( 'actionAdminDuplicateAfter', ['id_product' => $oldProductId, 'id_product_new' => $newProductId] ); //@todo: after ##21740 (transactions PR) is resolved. // Based on if its accepted or not, we need to implement roll back if something went wrong. // If transactions are accepted then we use it, else we manually rewind (delete the duplicate product) return new ProductId((int) $newProduct->id); } /** * @param ProductId $sourceProductId * @param ShopConstraint $shopConstraint * * @return Product */ private function duplicateProduct(ProductId $sourceProductId, ShopConstraint $shopConstraint): Product { $sourceDefaultShopId = $this->productRepository->getProductDefaultShopId($sourceProductId); $shopIds = $this->productRepository->getShopIdsByConstraint($sourceProductId, $shopConstraint); if (empty($shopIds)) { throw new ShopAssociationNotFound( sprintf( 'No shops associated with product %d by shop constraint %s', $sourceProductId->getValue(), var_export($shopConstraint, true) ) ); } if ($shopConstraint->getShopId()) { $targetDefaultShopId = $shopConstraint->getShopId(); } elseif ($shopConstraint->getShopGroupId()) { // If source default shop is in the group use it as new default, if not use the first shop from group $targetDefaultShopId = null; foreach ($shopIds as $groupShopId) { if ($groupShopId->getValue() === $sourceDefaultShopId->getValue()) { $targetDefaultShopId = $sourceDefaultShopId; } } if ($targetDefaultShopId === null) { $targetDefaultShopId = reset($shopIds); } } else { $targetDefaultShopId = $sourceDefaultShopId; } // First add the product to its default shop $sourceProduct = $this->productRepository->get($sourceProductId, $targetDefaultShopId); $duplicatedProduct = $this->duplicateObjectModelToShop($sourceProduct, $targetDefaultShopId); // Then associate it to other shops and copy its values $newProductId = new ProductId((int) $duplicatedProduct->id); foreach ($shopIds as $shopId) { $shopProduct = $this->productRepository->get($sourceProductId, $shopId); // The duplicated product is disabled and not indexed by default $shopProduct->indexed = false; $shopProduct->active = false; $shopProduct->date_add = date('Y-m-d H:i:s'); // Force a copy name to tell the two products apart (for each shop since name can be different on each shop) $shopProduct->name = $this->getNewProductName($shopProduct->name); // Force ID to update the new product $shopProduct->id = $shopProduct->id_product = $newProductId->getValue(); // Force the desired default shop so that it doesn't switch back to the source one $shopProduct->id_shop_default = $targetDefaultShopId->getValue(); $this->productRepository->update( $shopProduct, ShopConstraint::shop($shopId->getValue()), CannotUpdateProductException::FAILED_DUPLICATION ); } return $duplicatedProduct; } /** * @template T * @psalm-param T $sourceObjectModel * * @return T */ private function duplicateObjectModelToShop($sourceObjectModel, ShopId $targetDefaultShopId) { $duplicatedObject = clone $sourceObjectModel; unset($duplicatedObject->id); $objectDefinition = $sourceObjectModel::$definition; $idTable = 'id_' . $objectDefinition['table']; if (property_exists($duplicatedObject, $idTable)) { unset($duplicatedObject->$idTable); } $this->addObjectModelToShops($duplicatedObject, [$targetDefaultShopId], CannotDuplicateProductException::class); return $duplicatedObject; } /** * Provides duplicated product name * * @param array $oldProductLocalizedNames * * @return array */ private function getNewProductName(array $oldProductLocalizedNames): array { $newProductLocalizedNames = []; foreach ($oldProductLocalizedNames as $langId => $oldName) { $langId = (int) $langId; $namePattern = $this->translator->trans('copy of %s', [], 'Admin.Catalog.Feature', Language::getLocaleById($langId)); $newName = sprintf($namePattern, $oldName); $newProductLocalizedNames[$langId] = $this->stringModifier->cutEnd($newName, ProductSettings::MAX_NAME_LENGTH); } return $newProductLocalizedNames; } /** * Duplicates related product entities & associations * * @param int $oldProductId * @param int $newProductId * @param ShopConstraint $shopConstraint * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateRelations(int $oldProductId, int $newProductId, ShopConstraint $shopConstraint, string $productType): void { $shopIds = array_map(static function (ShopId $shopId) { return $shopId->getValue(); }, $this->productRepository->getShopIdsByConstraint(new ProductId($oldProductId), $shopConstraint)); $this->duplicateCategories($oldProductId, $newProductId); $combinationMatching = $this->duplicateCombinations($oldProductId, $newProductId, $shopIds); $this->duplicateSuppliers($oldProductId, $newProductId, $combinationMatching); $this->duplicateGroupReduction($oldProductId, $newProductId); $this->duplicateRelatedProducts($oldProductId, $newProductId); $this->duplicateFeatures($oldProductId, $newProductId); $this->duplicateSpecificPrices($oldProductId, $newProductId, $combinationMatching); $this->duplicatePackedProducts($oldProductId, $newProductId); $this->duplicateCustomizationFields($oldProductId, $newProductId); $this->duplicateTags($oldProductId, $newProductId); $this->duplicateVirtualProductFiles($oldProductId, $newProductId); $this->duplicateImages($oldProductId, $newProductId, $combinationMatching, $shopConstraint); $this->duplicateCarriers($oldProductId, $newProductId, $shopIds); $this->duplicateAttachmentAssociation($oldProductId, $newProductId); $this->duplicateStock($oldProductId, $newProductId, $shopIds, $productType, $combinationMatching); } /** * @param int $oldProductId * @param int $newProductId * @param int[] $shopIds * @param string $productType * @param array $combinationMatching */ private function duplicateStock(int $oldProductId, int $newProductId, array $shopIds, string $productType, array $combinationMatching): void { $targetProductId = new ProductId($newProductId); foreach ($shopIds as $shopId) { $targetShopId = new ShopId($shopId); try { $this->stockAvailableRepository->getForProduct($targetProductId, $targetShopId); } catch (StockAvailableNotFoundException $e) { // We create the new StockAvailable for this product and shop, it will then be updated via stock modification $this->stockAvailableRepository->createStockAvailable($targetProductId, $targetShopId); } try { $sourceStock = $this->stockAvailableRepository->getForProduct(new ProductId($oldProductId), $targetShopId); $outOfStock = new OutOfStockType((int) $sourceStock->out_of_stock); $productQuantity = (int) $sourceStock->quantity; $location = $sourceStock->location; } catch (StockAvailableNotFoundException $e) { // The source product may not have any associated StockAvailable (this happens with product created with old versions) $outOfStock = new OutOfStockType(OutOfStockType::OUT_OF_STOCK_DEFAULT); $productQuantity = 0; $location = ''; } $stockModification = StockModification::buildFixedQuantity($productQuantity); $stockProperties = new ProductStockProperties( $stockModification, $outOfStock, $location ); $this->productStockUpdater->update($targetProductId, $stockProperties, ShopConstraint::shop($targetShopId->getValue())); if ($productType === ProductType::TYPE_COMBINATIONS) { $this->duplicateCombinationsStock($oldProductId, $newProductId, $targetShopId, $combinationMatching); } } } /** * @param int $oldProductId * @param int $newProductId * @param ShopId $targetShopId * @param array $combinationMatching */ private function duplicateCombinationsStock(int $oldProductId, int $newProductId, ShopId $targetShopId, array $combinationMatching): void { $targetProductId = new ProductId($newProductId); $sourceCombinations = $this->combinationRepository->getCombinationIds( new ProductId($oldProductId), ShopConstraint::shop($targetShopId->getValue()) ); $targetConstraint = ShopConstraint::shop($targetShopId->getValue()); foreach ($sourceCombinations as $oldCombinationId) { $newCombinationId = new CombinationId($combinationMatching[$oldCombinationId->getValue()]); try { $this->stockAvailableRepository->getForCombination($newCombinationId, $targetShopId); } catch (StockAvailableNotFoundException $e) { $this->stockAvailableRepository->createStockAvailable($targetProductId, $targetShopId, $newCombinationId); } // Get the source stock try { $sourceStock = $this->stockAvailableRepository->getForCombination($oldCombinationId, $targetShopId); $combinationQuantity = (int) $sourceStock->quantity; $location = $sourceStock->location; } catch (StockAvailableNotFoundException $e) { // The source combination may not have any associated StockAvailable (this happens with combinations created with old versions) $combinationQuantity = 0; $location = ''; } $stockModification = StockModification::buildFixedQuantity($combinationQuantity); $stockProperties = new CombinationStockProperties( $stockModification, $location ); $this->combinationStockUpdater->update($newCombinationId, $stockProperties, $targetConstraint); } } /** * @param int $oldProductId * @param int $newProductId */ private function duplicateCategories(int $oldProductId, int $newProductId): void { $oldRows = $this->getRows('category_product', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_CATEGORIES); $newRows = []; $lastCategoriesPosition = []; foreach ($oldRows as $oldRow) { $categoryId = (int) $oldRow['id_category']; if (isset($lastCategoriesPosition[$categoryId])) { $lastCategoryPosition = $lastCategoriesPosition[$categoryId]; } else { $lastCategoryPosition = (int) $this->connection->createQueryBuilder() ->select('cp.position') ->from($this->dbPrefix . 'category_product', 'cp') ->where('cp.id_category = :categoryId') ->setParameter('categoryId', $categoryId) ->addOrderBy('position', 'DESC') ->execute() ->fetchOne() ; } $newRows[] = [ 'id_product' => $newProductId, 'id_category' => $categoryId, 'position' => ++$lastCategoryPosition, ]; $lastCategoriesPosition[$categoryId] = $lastCategoryPosition; } $this->bulkInsert('category_product', $newRows, CannotDuplicateProductException::FAILED_DUPLICATE_CATEGORIES); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateSuppliers(int $oldProductId, int $newProductId, array $combinationMatching): void { $oldSuppliers = $this->getRows('product_supplier', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_SUPPLIERS); if (empty($oldSuppliers)) { return; } foreach ($oldSuppliers as $oldSupplier) { $newProductSupplier = $this->productSupplierRepository->get(new ProductSupplierId((int) $oldSupplier['id_product_supplier'])); $newProductSupplier->id_product = $newProductId; $newProductSupplier->id_product_attribute = $combinationMatching[(int) $oldSupplier['id_product_attribute']] ?? 0; $this->productSupplierRepository->add($newProductSupplier); } } /** * @param int $oldProductId * @param int $newProductId * @param int[] $shopIds * * @return array Combination matching (key is the old ID, value is the new one) * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateCombinations(int $oldProductId, int $newProductId, array $shopIds): array { $oldCombinationsShop = $this->getRows( 'product_attribute_shop', [ 'id_product' => $oldProductId, 'id_shop' => $shopIds, ], CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS, [ 'id_product_attribute' => 'ASC', 'id_shop' => 'ASC', ] ); // First create new combinations which are copies of the old ones $combinationMatching = []; $newShopAssociations = []; foreach ($oldCombinationsShop as $oldCombinationShop) { $oldCombinationId = (int) $oldCombinationShop['id_product_attribute']; if (!isset($combinationMatching[$oldCombinationId])) { // New combination to create, copy the old combination and associate to appropriate attributes, store the new ID for matching $oldCombinations = $this->getRows( 'product_attribute', [ 'id_product' => $oldProductId, 'id_product_attribute' => $oldCombinationId, ], CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS ); $newCombination = array_merge(reset($oldCombinations), [ 'id_product' => $newProductId, 'id_product_attribute' => null, ]); $newCombinationId = $this->insertRow('product_attribute', $newCombination, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS); if (empty($newCombinationId)) { throw new CannotDuplicateProductException('Could not duplicate combination', CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS); } $combinationMatching[$oldCombinationId] = $newCombinationId; // Associate attributes to combination $oldAttributes = $this->getRows( 'product_attribute_combination', ['id_product_attribute' => $oldCombinationId], CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS ); $newAttributes = $this->replaceInRows($oldAttributes, ['id_product_attribute' => $newCombinationId]); $this->bulkInsert('product_attribute_combination', $newAttributes, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS); } // Add new shop association $newCombinationId = $combinationMatching[$oldCombinationId]; $newCombinationShop = array_merge($oldCombinationShop, [ 'id_product_attribute' => $newCombinationId, 'id_product' => $newProductId, ]); $newShopAssociations[] = $newCombinationShop; } // Insert all shop associations $this->bulkInsert('product_attribute_shop', $newShopAssociations, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS); // Finally copy all combination multi lang fields $oldCombinationsLang = $this->getRows( 'product_attribute_lang', [ 'id_product_attribute' => array_keys($combinationMatching), ], CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS ); $newCombinationsLang = []; foreach ($oldCombinationsLang as $oldLang) { $newCombinationsLang[] = array_merge($oldLang, [ 'id_product_attribute' => $combinationMatching[(int) $oldLang['id_product_attribute']], ]); } $this->bulkInsert('product_attribute_lang', $newCombinationsLang, CannotDuplicateProductException::FAILED_DUPLICATE_COMBINATIONS); return $combinationMatching; } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateGroupReduction(int $oldProductId, int $newProductId): void { $this->duplicateProductTable('product_group_reduction_cache', $oldProductId, $newProductId, CannotDuplicateProductException::FAILED_DUPLICATE_GROUP_REDUCTION); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateRelatedProducts(int $oldProductId, int $newProductId): void { $oldRows = $this->getRows( 'accessory', ['id_product_1' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_RELATED_PRODUCTS ); if (empty($oldRows)) { return; } $newRows = $this->replaceInRows($oldRows, ['id_product_1' => $newProductId]); $this->bulkInsert( 'accessory', $newRows, CannotDuplicateProductException::FAILED_DUPLICATE_RELATED_PRODUCTS ); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateFeatures(int $oldProductId, int $newProductId): void { $oldProductFeatures = $this->getRows('feature_product', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES); // Custom values need to be copied and assigned to new products $featureValuesIds = array_map(static function (array $oldProductFeature) { return (int) $oldProductFeature['id_feature_value']; }, $oldProductFeatures); $customFeatureValues = $this->getRows('feature_value', ['id_feature_value' => $featureValuesIds, 'custom' => 1], CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES); $customValuesMapping = []; if (!empty($customFeatureValues)) { $lastFeatureValueId = (int) $this->connection->createQueryBuilder() ->from($this->dbPrefix . 'feature_value') ->select('id_feature_value') ->addOrderBy('id_feature_value', 'DESC') ->execute() ->fetchOne() ; $newCustomFeatureValues = []; $newCustomFeatureValuesLang = []; foreach ($customFeatureValues as $customFeatureValue) { $newCustomFeatureValueId = ++$lastFeatureValueId; $oldCustomFeatureValueId = (int) $customFeatureValue['id_feature_value']; $customValuesMapping[$oldCustomFeatureValueId] = $newCustomFeatureValueId; $newCustomFeatureValues[] = array_merge($customFeatureValue, [ 'id_feature_value' => $newCustomFeatureValueId, ]); $langData = $this->getRows('feature_value_lang', ['id_feature_value' => $oldCustomFeatureValueId], CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES); $langData = $this->replaceInRows($langData, ['id_feature_value' => $newCustomFeatureValueId]); $newCustomFeatureValuesLang = array_merge($newCustomFeatureValuesLang, $langData); } $this->bulkInsert('feature_value', $newCustomFeatureValues, CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES); $this->bulkInsert('feature_value_lang', $newCustomFeatureValuesLang, CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES); } // Now we can duplicate relations (and replace custom ones with newly copied feature values) $newProductFeatures = []; foreach ($oldProductFeatures as $oldProductFeature) { $oldCustomFeatureValueId = (int) $oldProductFeature['id_feature_value']; if (!isset($customValuesMapping[$oldCustomFeatureValueId])) { $newProductFeatures[] = [ 'id_product' => $newProductId, 'id_feature' => $oldProductFeature['id_feature'], 'id_feature_value' => $oldProductFeature['id_feature_value'], ]; } else { $newProductFeatures[] = [ 'id_product' => $newProductId, 'id_feature' => $oldProductFeature['id_feature'], 'id_feature_value' => $customValuesMapping[$oldCustomFeatureValueId], ]; } } $this->bulkInsert('feature_product', $newProductFeatures, CannotDuplicateProductException::FAILED_DUPLICATE_FEATURES); } /** * @param int $oldProductId * @param int $newProductId * @param array $combinationMatching * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateSpecificPrices(int $oldProductId, int $newProductId, array $combinationMatching): void { $specificPriceIds = $this->specificPriceRepository->getProductSpecificPricesIds(new ProductId($oldProductId)); foreach ($specificPriceIds as $specificPriceId) { $specificPrice = $this->specificPriceRepository->get($specificPriceId); $specificPrice->id_product = $newProductId; $specificPrice->id_product_attribute = $combinationMatching[(int) $specificPrice->id_product_attribute] ?? 0; $this->specificPriceRepository->add($specificPrice); } // Duplicate priorities $oldPriorities = $this->getRows('specific_price_priority', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_SPECIFIC_PRICES); $newPriorities = $this->replaceInRows($oldPriorities, ['id_product' => $newProductId, 'id_specific_price_priority' => null]); $this->bulkInsert('specific_price_priority', $newPriorities, CannotDuplicateProductException::FAILED_DUPLICATE_SPECIFIC_PRICES); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicatePackedProducts(int $oldProductId, int $newProductId): void { $oldPackContent = $this->getRows('pack', ['id_product_pack' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_PACKED_PRODUCTS); $newPackContent = $this->replaceInRows($oldPackContent, ['id_product_pack' => $newProductId]); $this->bulkInsert('pack', $newPackContent, CannotDuplicateProductException::FAILED_DUPLICATE_PACKED_PRODUCTS); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException */ private function duplicateCustomizationFields(int $oldProductId, int $newProductId): void { $oldCustomizationFields = $this->getRows('customization_field', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS); $lastCustomizationFieldId = (int) $this->connection->createQueryBuilder() ->from($this->dbPrefix . 'customization_field') ->select('id_customization_field') ->addOrderBy('id_customization_field', 'DESC') ->execute() ->fetchOne() ; $newCustomizationFields = []; $newCustomizationFieldsLang = []; foreach ($oldCustomizationFields as $oldCustomizationField) { $oldCustomizationFieldId = (int) $oldCustomizationField['id_customization_field']; $newCustomizationFieldId = ++$lastCustomizationFieldId; $newCustomizationFields[] = array_merge($oldCustomizationField, [ 'id_product' => $newProductId, 'id_customization_field' => $newCustomizationFieldId, ]); $oldCustomizationFieldsLang = $this->getRows('customization_field_lang', ['id_customization_field' => $oldCustomizationFieldId], CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS); foreach ($oldCustomizationFieldsLang as $oldCustomizationFieldLang) { $newCustomizationFieldsLang[] = array_merge($oldCustomizationFieldLang, [ 'id_customization_field' => $newCustomizationFieldId, ]); } } $this->bulkInsert('customization_field', $newCustomizationFields, CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS); $this->bulkInsert('customization_field_lang', $newCustomizationFieldsLang, CannotDuplicateProductException::FAILED_DUPLICATE_CUSTOMIZATION_FIELDS); } /** * @param int $oldProductId * @param int $newProductId */ private function duplicateTags(int $oldProductId, int $newProductId): void { $this->duplicateProductTable( 'product_tag', $oldProductId, $newProductId, CannotDuplicateProductException::FAILED_DUPLICATE_TAGS ); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateVirtualProductFiles(int $oldProductId, int $newProductId): void { $oldVirtualProductFiles = $this->getRows('product_download', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_DOWNLOADS); $newVirtualProductFiles = []; foreach ($oldVirtualProductFiles as $oldVirtualProductFile) { $newFilename = VirtualProductFile::getNewFilename(); copy(_PS_DOWNLOAD_DIR_ . $oldVirtualProductFile['filename'], _PS_DOWNLOAD_DIR_ . $newFilename); $newVirtualProductFiles[] = array_merge($oldVirtualProductFile, [ 'id_product_download' => null, 'id_product' => $newProductId, 'filename' => $newFilename, 'date_add' => date('Y-m-d H:i:s'), ]); } $this->bulkInsert('product_download', $newVirtualProductFiles, CannotDuplicateProductException::FAILED_DUPLICATE_DOWNLOADS); } /** * @param int $oldProductId * @param int $newProductId * @param array $combinationMatching * @param ShopConstraint $shopConstraint * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateImages(int $oldProductId, int $newProductId, array $combinationMatching, ShopConstraint $shopConstraint): void { $oldImages = $this->getRows('image', ['id_product' => $oldProductId], CannotDuplicateProductException::FAILED_DUPLICATE_IMAGES); $imagesMapping = []; $fs = new Filesystem(); foreach ($oldImages as $oldImage) { $oldImageId = new ImageId((int) $oldImage['id_image']); $newImage = $this->productImageRepository->duplicate($oldImageId, new ProductId($newProductId), $shopConstraint); if (null === $newImage) { continue; } $newImageId = new ImageId((int) $newImage->id); $imageTypes = $this->productImageRepository->getProductImageTypes(); // Copy the generated images instead of generating them is more performant foreach ($imageTypes as $imageType) { $fs->copy( $this->productImageSystemPathFactory->getPathByType($oldImageId, $imageType->name), $this->productImageSystemPathFactory->getPathByType($newImageId, $imageType->name) ); } // Also copy original $oldOriginalPath = $this->productImageSystemPathFactory->getPath($oldImageId); $newOriginalPath = $this->productImageSystemPathFactory->getPath($newImageId); $fs->copy( $oldOriginalPath, $newOriginalPath ); // And fileType $originalFileTypePath = dirname($oldOriginalPath) . '/fileType'; if (file_exists($originalFileTypePath)) { $fs->copy( $originalFileTypePath, dirname($newOriginalPath) . '/fileType' ); } $imagesMapping[$oldImageId->getValue()] = $newImageId->getValue(); } $oldCombinationImages = $this->getRows('product_attribute_image', ['id_image' => array_keys($imagesMapping)], CannotDuplicateProductException::FAILED_DUPLICATE_IMAGES); $newCombinationImages = []; foreach ($oldCombinationImages as $oldCombinationImage) { $newCombinationImages[] = [ 'id_image' => $imagesMapping[(int) $oldCombinationImage['id_image']], 'id_product_attribute' => $combinationMatching[(int) $oldCombinationImage['id_product_attribute']], ]; } $this->bulkInsert('product_attribute_image', $newCombinationImages, CannotDuplicateProductException::FAILED_DUPLICATE_IMAGES); } /** * @param int $oldProductId * @param int $newProductId * @param int[] $shopIds * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateCarriers(int $oldProductId, int $newProductId, array $shopIds): void { $this->duplicateProductTableForShops( 'product_carrier', $oldProductId, $newProductId, $shopIds, CannotDuplicateProductException::FAILED_DUPLICATE_CARRIERS ); } /** * @param int $oldProductId * @param int $newProductId * * @throws CannotDuplicateProductException * @throws CoreException */ private function duplicateAttachmentAssociation(int $oldProductId, int $newProductId): void { $this->duplicateProductTable('product_attachment', $oldProductId, $newProductId, CannotDuplicateProductException::FAILED_DUPLICATE_ATTACHMENT_ASSOCIATION); } /** * @param int $newProductId * @param int $oldProductId * * @throws CannotUpdateProductException * @throws CoreException */ private function updateDefaultAttribute(int $newProductId, int $oldProductId): void { try { if (!Product::updateDefaultAttribute($newProductId)) { throw new CannotUpdateProductException( sprintf('Failed to update default attribute when duplicating product %d', $oldProductId), CannotUpdateProductException::FAILED_UPDATE_DEFAULT_ATTRIBUTE ); } } catch (PrestaShopException $e) { throw new CoreException( sprintf('Error occurred when trying to duplicate product #%d. Failed to update default attribute', $oldProductId), 0, $e ); } } /** * Fetch all rows related to a product on a specific table and duplicate it by replacing only the column id_product. * * @param string $table * @param int $oldProductId * @param int $newProductId * @param int $errorCode * * @throws InvalidArgumentException * @throws CannotDuplicateProductException */ private function duplicateProductTable(string $table, int $oldProductId, int $newProductId, int $errorCode): void { $oldRows = $this->getRows($table, ['id_product' => $oldProductId], $errorCode); if (empty($oldRows)) { return; } $newRows = $this->replaceInRows($oldRows, ['id_product' => $newProductId]); $this->bulkInsert($table, $newRows, $errorCode); } /** * Fetch all rows related to a product on a specific table for a set of shop IDs and duplicate it by replacing only the column id_product. * * @param string $table * @param int $oldProductId * @param int $newProductId * @param int[] $shopIds * @param int $errorCode * * @throws InvalidArgumentException * @throws CannotDuplicateProductException */ private function duplicateProductTableForShops(string $table, int $oldProductId, int $newProductId, array $shopIds, int $errorCode): void { $oldRows = $this->getRows($table, [ 'id_product' => $oldProductId, 'id_shop' => $shopIds, ], $errorCode); if (empty($oldRows)) { return; } $newRows = $this->replaceInRows($oldRows, ['id_product' => $newProductId]); $this->bulkInsert($table, $newRows, $errorCode); } /** * Bulk insert one row, the values is an associative array defining each column in the row. * * @param string $table * @param array $rowValues * @param int $errorCode * * @return int */ private function insertRow(string $table, array $rowValues, int $errorCode): int { $this->bulkInsert($table, [$rowValues], $errorCode); return (int) $this->connection->lastInsertId(); } /** * Bulk insert some row values, all row must be formatted with the exact same keys and in the same order * so that the defined column match the values for each row. * * @param string $table * @param array $multipleRowValues * @param int $errorCode */ private function bulkInsert(string $table, array $multipleRowValues, int $errorCode): void { if (empty($multipleRowValues)) { return; } $insertKeys = array_keys(reset($multipleRowValues)); $bulkInsertSql = 'INSERT INTO ' . $this->dbPrefix . $table . ' (' . implode(',', $insertKeys) . ') VALUES '; foreach ($multipleRowValues as $i => $rowValue) { if (array_keys($rowValue) !== $insertKeys) { throw new InvalidArgumentException('The provided data has different keys in some rows'); } $bulkInsertSql .= '(' . implode(',', array_map(static function ($columnValue): string { if ($columnValue === null) { return 'null'; } elseif (!empty($columnValue) && DateTime::isNull($columnValue)) { // We can't use 0000-00-00 as a value it's not valid in Mysql, so we use null instead return 'null'; } if (is_string($columnValue)) { $columnValue = str_replace("'", "''", $columnValue); } // We stringify values to avoid SQL syntax error, the float and integers will correctly casted in the DB anyway // however string values and date time need to be quoted return "'$columnValue'"; }, $rowValue)) . ')'; if ($i < count($multipleRowValues) - 1) { $bulkInsertSql .= ','; } else { $bulkInsertSql .= ';'; } } try { $this->connection->executeStatement($bulkInsertSql); } catch (Exception $e) { throw new CannotDuplicateProductException( sprintf('Cannot bulk insert into table %s failed', $table), $errorCode ); } } /** * Replace columns values in every row. * * @param array $rows * @param array $replacements * * @return array */ private function replaceInRows(array $rows, array $replacements): array { $replacedRows = []; foreach ($rows as $key => $row) { $replacedRows[$key] = array_merge($row, $replacements); } return $replacedRows; } /** * Returns all the columns of a specific table, you can add criteria to filter, prefix is automatically added. * * @param string $table * @param array $criteria * @param int $errorCode * @param array> $orderBy * * @return array */ private function getRows(string $table, array $criteria, int $errorCode, array $orderBy = []): array { $qb = $this->connection ->createQueryBuilder() ->from($this->dbPrefix . $table) ->select('*') ; foreach ($criteria as $column => $value) { if (is_array($value)) { $arrayType = is_int(reset($value)) ? Connection::PARAM_INT_ARRAY : Connection::PARAM_STR_ARRAY; $qb ->andWhere("$column IN (:$column)") ->setParameter(":$column", $value, $arrayType) ; } else { $qb ->andWhere("$column = :$column") ->setParameter(":$column", $value) ; } } foreach ($orderBy as $orderKey => $orderWay) { $qb->addOrderBy($orderKey, $orderWay); } try { $rows = $qb->execute()->fetchAllAssociative(); } catch (Exception $e) { throw new CannotDuplicateProductException( sprintf('Cannot select rows from table %s', $this->dbPrefix . $table), $errorCode ); } return $rows; } }