1<?php
2/**
3 * Copyright since 2007 PrestaShop SA and Contributors
4 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
5 *
6 * NOTICE OF LICENSE
7 *
8 * This source file is subject to the Open Software License (OSL 3.0)
9 * that is bundled with this package in the file LICENSE.md.
10 * It is also available through the world-wide-web at this URL:
11 * https://opensource.org/licenses/OSL-3.0
12 * If you did not receive a copy of the license and are unable to
13 * obtain it through the world-wide-web, please send an email
14 * to license@prestashop.com so we can send you a copy immediately.
15 *
16 * DISCLAIMER
17 *
18 * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
19 * versions in the future. If you wish to customize PrestaShop for your
20 * needs please refer to https://devdocs.prestashop.com/ for more information.
21 *
22 * @author    PrestaShop SA and Contributors <contact@prestashop.com>
23 * @copyright Since 2007 PrestaShop SA and Contributors
24 * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
25 */
26
27declare(strict_types=1);
28
29namespace PrestaShop\PrestaShop\Adapter\Product\Repository;
30
31use Doctrine\DBAL\Connection;
32use PrestaShop\Decimal\DecimalNumber;
33use PrestaShop\PrestaShop\Adapter\AbstractObjectModelRepository;
34use PrestaShop\PrestaShop\Adapter\Manufacturer\Repository\ManufacturerRepository;
35use PrestaShop\PrestaShop\Adapter\Product\Validate\ProductValidator;
36use PrestaShop\PrestaShop\Adapter\TaxRulesGroup\Repository\TaxRulesGroupRepository;
37use PrestaShop\PrestaShop\Core\Domain\Language\ValueObject\LanguageId;
38use PrestaShop\PrestaShop\Core\Domain\Manufacturer\ValueObject\ManufacturerId;
39use PrestaShop\PrestaShop\Core\Domain\Manufacturer\ValueObject\NoManufacturerId;
40use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotAddProductException;
41use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotBulkDeleteProductException;
42use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotDeleteProductException;
43use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotDuplicateProductException;
44use PrestaShop\PrestaShop\Core\Domain\Product\Exception\CannotUpdateProductException;
45use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductConstraintException;
46use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductException;
47use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException;
48use PrestaShop\PrestaShop\Core\Domain\Product\Pack\Exception\ProductPackConstraintException;
49use PrestaShop\PrestaShop\Core\Domain\Product\ProductTaxRulesGroupSettings;
50use PrestaShop\PrestaShop\Core\Domain\Product\Stock\Exception\ProductStockConstraintException;
51use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductId;
52use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType;
53use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopId;
54use PrestaShop\PrestaShop\Core\Domain\TaxRulesGroup\ValueObject\TaxRulesGroupId;
55use PrestaShop\PrestaShop\Core\Exception\CoreException;
56use PrestaShopException;
57use Product;
58
59/**
60 * Methods to access data storage for Product
61 */
62class ProductRepository extends AbstractObjectModelRepository
63{
64    /**
65     * @var Connection
66     */
67    private $connection;
68
69    /**
70     * @var string
71     */
72    private $dbPrefix;
73
74    /**
75     * @var ProductValidator
76     */
77    private $productValidator;
78
79    /**
80     * @var int
81     */
82    private $defaultCategoryId;
83
84    /**
85     * @var TaxRulesGroupRepository
86     */
87    private $taxRulesGroupRepository;
88
89    /**
90     * @var ManufacturerRepository
91     */
92    private $manufacturerRepository;
93
94    /**
95     * @param Connection $connection
96     * @param string $dbPrefix
97     * @param ProductValidator $productValidator
98     * @param int $defaultCategoryId
99     * @param TaxRulesGroupRepository $taxRulesGroupRepository
100     * @param ManufacturerRepository $manufacturerRepository
101     */
102    public function __construct(
103        Connection $connection,
104        string $dbPrefix,
105        ProductValidator $productValidator,
106        int $defaultCategoryId,
107        TaxRulesGroupRepository $taxRulesGroupRepository,
108        ManufacturerRepository $manufacturerRepository
109    ) {
110        $this->connection = $connection;
111        $this->dbPrefix = $dbPrefix;
112        $this->productValidator = $productValidator;
113        $this->defaultCategoryId = $defaultCategoryId;
114        $this->taxRulesGroupRepository = $taxRulesGroupRepository;
115        $this->manufacturerRepository = $manufacturerRepository;
116    }
117
118    /**
119     * Duplicates product entity without relations
120     *
121     * @param Product $product
122     *
123     * @return Product
124     *
125     * @throws CoreException
126     * @throws CannotDuplicateProductException
127     * @throws ProductConstraintException
128     * @throws ProductException
129     */
130    public function duplicate(Product $product): Product
131    {
132        unset($product->id, $product->id_product);
133
134        $this->productValidator->validateCreation($product);
135        $this->productValidator->validate($product);
136        $this->addObjectModel($product, CannotDuplicateProductException::class);
137
138        return $product;
139    }
140
141    /**
142     * Gets product price by provided shop
143     *
144     * @param ProductId $productId
145     * @param ShopId $shopId
146     *
147     * @return DecimalNumber|null
148     */
149    public function getPriceByShop(ProductId $productId, ShopId $shopId): ?DecimalNumber
150    {
151        $qb = $this->connection->createQueryBuilder();
152        $qb->select('price')
153            ->from($this->dbPrefix . 'product_shop')
154            ->where('id_product = :productId')
155            ->andWhere('id_shop = :shopId')
156            ->setParameter('productId', $productId->getValue())
157            ->setParameter('shopId', $shopId->getValue())
158        ;
159
160        $result = $qb->execute()->fetch();
161
162        if (!$result) {
163            return null;
164        }
165
166        return new DecimalNumber($result['price']);
167    }
168
169    /**
170     * @param ProductId $productId
171     */
172    public function assertProductExists(ProductId $productId): void
173    {
174        $this->assertObjectModelExists($productId->getValue(), 'product', ProductNotFoundException::class);
175    }
176
177    /**
178     * @param ProductId[] $productIds
179     *
180     * @throws ProductNotFoundException
181     */
182    public function assertAllProductsExists(array $productIds): void
183    {
184        //@todo: no shop association. Should it be checked here?
185        $ids = array_map(function (ProductId $productId): int {
186            return $productId->getValue();
187        }, $productIds);
188        $ids = array_unique($ids);
189
190        $qb = $this->connection->createQueryBuilder();
191        $qb->select('COUNT(id_product) as product_count')
192            ->from($this->dbPrefix . 'product')
193            ->where('id_product IN (:productIds)')
194            ->setParameter('productIds', $ids, Connection::PARAM_INT_ARRAY)
195        ;
196
197        $results = $qb->execute()->fetch();
198
199        if (!$results || (int) $results['product_count'] !== count($ids)) {
200            throw new ProductNotFoundException(
201                    sprintf(
202                        'Some of these products do not exist: %s',
203                        implode(',', $ids)
204                    )
205                );
206        }
207    }
208
209    /**
210     * @param ProductId $productId
211     * @param LanguageId $languageId
212     *
213     * @return array<array<string, string>>
214     *                             e.g [
215     *                             ['id_product' => '1', 'name' => 'Product name', 'reference' => 'demo15'],
216     *                             ['id_product' => '2', 'name' => 'Product name2', 'reference' => 'demo16'],
217     *                             ]
218     *
219     * @throws CoreException
220     */
221    public function getRelatedProducts(ProductId $productId, LanguageId $languageId): array
222    {
223        $this->assertProductExists($productId);
224        $productIdValue = $productId->getValue();
225
226        try {
227            $accessories = Product::getAccessoriesLight($languageId->getValue(), $productIdValue);
228        } catch (PrestaShopException $e) {
229            throw new CoreException(sprintf(
230                'Error occurred when fetching related products for product #%d',
231                $productIdValue
232            ));
233        }
234
235        return $accessories;
236    }
237
238    /**
239     * @param ProductId $productId
240     *
241     * @return Product
242     *
243     * @throws CoreException
244     */
245    public function get(ProductId $productId): Product
246    {
247        /** @var Product $product */
248        $product = $this->getObjectModel(
249            $productId->getValue(),
250            Product::class,
251            ProductNotFoundException::class
252        );
253
254        try {
255            $product->loadStockData();
256        } catch (PrestaShopException $e) {
257            throw new CoreException(
258                sprintf('Error occurred when trying to load Product stock #%d', $productId->getValue()),
259                0,
260                $e
261            );
262        }
263
264        return $product;
265    }
266
267    /**
268     * @param array<int, string> $localizedNames
269     * @param string $productType
270     *
271     * @return Product
272     *
273     * @throws CannotAddProductException
274     */
275    public function create(array $localizedNames, string $productType): Product
276    {
277        $product = new Product();
278        $product->active = false;
279        $product->id_category_default = $this->defaultCategoryId;
280        $product->name = $localizedNames;
281        $product->is_virtual = ProductType::TYPE_VIRTUAL === $productType;
282        $product->cache_is_pack = ProductType::TYPE_PACK === $productType;
283        $product->product_type = $productType;
284
285        $this->productValidator->validateCreation($product);
286        $this->addObjectModel($product, CannotAddProductException::class);
287        $product->addToCategories([$product->id_category_default]);
288
289        return $product;
290    }
291
292    /**
293     * @param Product $product
294     * @param array $propertiesToUpdate
295     * @param int $errorCode
296     *
297     * @throws CoreException
298     * @throws ProductConstraintException
299     * @throws ProductPackConstraintException
300     * @throws ProductStockConstraintException
301     */
302    public function partialUpdate(Product $product, array $propertiesToUpdate, int $errorCode): void
303    {
304        $taxRulesGroupIdIsBeingUpdated = in_array('id_tax_rules_group', $propertiesToUpdate, true);
305        $taxRulesGroupId = (int) $product->id_tax_rules_group;
306        $manufacturerIdIsBeingUpdated = in_array('id_manufacturer', $propertiesToUpdate, true);
307        $manufacturerId = (int) $product->id_manufacturer;
308
309        if ($taxRulesGroupIdIsBeingUpdated && $taxRulesGroupId !== ProductTaxRulesGroupSettings::NONE_APPLIED) {
310            $this->taxRulesGroupRepository->assertTaxRulesGroupExists(new TaxRulesGroupId($taxRulesGroupId));
311        }
312        if ($manufacturerIdIsBeingUpdated && $manufacturerId !== NoManufacturerId::NO_MANUFACTURER_ID) {
313            $this->manufacturerRepository->assertManufacturerExists(new ManufacturerId($manufacturerId));
314        }
315
316        $this->productValidator->validate($product);
317        $this->partiallyUpdateObjectModel(
318            $product,
319            $propertiesToUpdate,
320            CannotUpdateProductException::class,
321            $errorCode
322        );
323    }
324
325    /**
326     * @param ProductId $productId
327     *
328     * @throws CoreException
329     */
330    public function delete(ProductId $productId): void
331    {
332        $this->deleteObjectModel($this->get($productId), CannotDeleteProductException::class);
333    }
334
335    /**
336     * @param array $productIds
337     *
338     * @throws CannotBulkDeleteProductException
339     */
340    public function bulkDelete(array $productIds): void
341    {
342        $failedIds = [];
343        foreach ($productIds as $productId) {
344            try {
345                $this->delete($productId);
346            } catch (CannotDeleteProductException $e) {
347                $failedIds[] = $productId->getValue();
348            }
349        }
350
351        if (empty($failedIds)) {
352            return;
353        }
354
355        throw new CannotBulkDeleteProductException(
356            $failedIds,
357            sprintf('Failed to delete following products: "%s"', implode(', ', $failedIds))
358        );
359    }
360}
361