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
27namespace PrestaShop\PrestaShop\Adapter\Product;
28
29use AdminProductsController;
30use Attachment;
31use Category;
32use Combination;
33use Configuration;
34use Context;
35use Customer;
36use Db;
37use Hook;
38use Image;
39use Language;
40use ObjectModel;
41use PrestaShop\PrestaShop\Adapter\Entity\Customization;
42use PrestaShop\PrestaShop\Core\Foundation\Database\EntityNotFoundException;
43use PrestaShop\PrestaShop\Core\Localization\Locale;
44use PrestaShopBundle\Form\Admin\Type\CustomMoneyType;
45use PrestaShopBundle\Utils\FloatParser;
46use Product;
47use ProductDownload;
48use Shop;
49use ShopUrl;
50use SpecificPrice;
51use SpecificPriceRule;
52use StockAvailable;
53use Symfony\Component\Translation\TranslatorInterface;
54use Tax;
55use Tools;
56use Validate;
57
58/**
59 * Admin controller wrapper for new Architecture, about Product admin controller.
60 */
61class AdminProductWrapper
62{
63    /**
64     * @var array
65     */
66    private $errors = [];
67
68    /**
69     * @var Locale
70     */
71    private $locale;
72
73    /**
74     * @var TranslatorInterface
75     */
76    private $translator;
77
78    /**
79     * @var array
80     */
81    private $employeeAssociatedShops;
82
83    /**
84     * @var FloatParser
85     */
86    private $floatParser;
87
88    /**
89     * Constructor : Inject Symfony\Component\Translation Translator.
90     *
91     * @param object $translator
92     * @param array $employeeAssociatedShops
93     * @param Locale $locale
94     * @param FloatParser|null $floatParser
95     */
96    public function __construct($translator, array $employeeAssociatedShops, Locale $locale, FloatParser $floatParser = null)
97    {
98        $this->translator = $translator;
99        $this->employeeAssociatedShops = $employeeAssociatedShops;
100        $this->locale = $locale;
101        $this->floatParser = $floatParser ?? new FloatParser();
102    }
103
104    /**
105     * getInstance
106     * Get the legacy AdminProductsControllerCore instance.
107     *
108     * @return AdminProductsController instance
109     */
110    public function getInstance()
111    {
112        return new AdminProductsController();
113    }
114
115    /**
116     * processProductAttribute
117     * Update a combination.
118     *
119     * @param object $product
120     * @param array $combinationValues the posted values
121     *
122     * @return void
123     */
124    public function processProductAttribute($product, $combinationValues)
125    {
126        $id_product_attribute = (int) $combinationValues['id_product_attribute'];
127        $images = [];
128
129        if (!Combination::isFeatureActive() || $id_product_attribute == 0) {
130            return;
131        }
132
133        if (!isset($combinationValues['attribute_wholesale_price'])) {
134            $combinationValues['attribute_wholesale_price'] = 0;
135        }
136        if (!isset($combinationValues['attribute_price_impact'])) {
137            $combinationValues['attribute_price_impact'] = 0;
138        }
139        if (!isset($combinationValues['attribute_weight_impact'])) {
140            $combinationValues['attribute_weight_impact'] = 0;
141        }
142
143        // This is VERY UGLY, but since ti ComputingPrecision can never return enough decimals for now we have no
144        // choice but to hard code this one to make sure enough precision is saved in the DB or it results in errors
145        // of 1 cent in the shop
146        $computingPrecision = CustomMoneyType::PRESTASHOP_DECIMALS;
147        if (!isset($combinationValues['attribute_ecotax']) || 0.0 === (float) $combinationValues['attribute_ecotax']) {
148            $combinationValues['attribute_ecotax'] = 0;
149        } else {
150            // Value is displayed tax included but must be saved tax excluded
151            $combinationValues['attribute_ecotax'] = Tools::ps_round(
152                $combinationValues['attribute_ecotax'] / (1 + Tax::getProductEcotaxRate() / 100),
153                $computingPrecision
154            );
155        }
156        if ((isset($combinationValues['attribute_default']) && $combinationValues['attribute_default'] == 1)) {
157            $product->deleteDefaultAttributes();
158        }
159        if (!empty($combinationValues['id_image_attr'])) {
160            $images = $combinationValues['id_image_attr'];
161        } else {
162            $combination = new Combination($id_product_attribute);
163            $combination->setImages([]);
164        }
165        if (!isset($combinationValues['attribute_low_stock_threshold'])) {
166            $combinationValues['attribute_low_stock_threshold'] = null;
167        }
168        if (!isset($combinationValues['attribute_low_stock_alert'])) {
169            $combinationValues['attribute_low_stock_alert'] = false;
170        }
171
172        $product->updateAttribute(
173            $id_product_attribute,
174            $combinationValues['attribute_wholesale_price'],
175            $combinationValues['attribute_price'] * $combinationValues['attribute_price_impact'],
176            $combinationValues['attribute_weight'] * $combinationValues['attribute_weight_impact'],
177            $combinationValues['attribute_unity'] * $combinationValues['attribute_unit_impact'],
178            $combinationValues['attribute_ecotax'],
179            $images,
180            $combinationValues['attribute_reference'],
181            $combinationValues['attribute_ean13'],
182            (isset($combinationValues['attribute_default']) && $combinationValues['attribute_default'] == 1),
183            isset($combinationValues['attribute_location']) ? $combinationValues['attribute_location'] : null,
184            $combinationValues['attribute_upc'],
185            $combinationValues['attribute_minimal_quantity'],
186            $combinationValues['available_date_attribute'],
187            false,
188            [],
189            $combinationValues['attribute_isbn'],
190            $combinationValues['attribute_low_stock_threshold'],
191            $combinationValues['attribute_low_stock_alert'],
192            $combinationValues['attribute_mpn']
193        );
194
195        StockAvailable::setProductDependsOnStock((int) $product->id, $product->depends_on_stock, null, $id_product_attribute);
196        StockAvailable::setProductOutOfStock((int) $product->id, $product->out_of_stock, null, $id_product_attribute);
197        StockAvailable::setLocation((int) $product->id, $combinationValues['attribute_location'], null, $id_product_attribute);
198
199        $product->checkDefaultAttributes();
200
201        if ((isset($combinationValues['attribute_default']) && $combinationValues['attribute_default'] == 1)) {
202            Product::updateDefaultAttribute((int) $product->id);
203            $product->cache_default_attribute = (int) $id_product_attribute;
204
205            // We need to reload the product because some other calls have modified the database
206            // It's done just for the setAvailableDate to avoid side effects
207            Product::disableCache();
208            $consistentProduct = new Product($product->id);
209            if ($available_date = $combinationValues['available_date_attribute']) {
210                $consistentProduct->setAvailableDate($available_date);
211            } else {
212                $consistentProduct->setAvailableDate();
213            }
214            Product::enableCache();
215        }
216
217        if (isset($combinationValues['attribute_quantity'])) {
218            $this->processQuantityUpdate($product, $combinationValues['attribute_quantity'], $id_product_attribute);
219        }
220    }
221
222    /**
223     * Update a quantity for a product or a combination.
224     *
225     * Does not work in Advanced stock management.
226     *
227     * @param Product $product
228     * @param int $quantity
229     * @param int $forAttributeId
230     */
231    public function processQuantityUpdate(Product $product, $quantity, $forAttributeId = 0)
232    {
233        // Hook triggered by legacy code below: actionUpdateQuantity('id_product', 'id_product_attribute', 'quantity')
234        StockAvailable::setQuantity((int) $product->id, $forAttributeId, $quantity);
235        Hook::exec('actionProductUpdate', ['id_product' => (int) $product->id, 'product' => $product]);
236    }
237
238    /**
239     * Update the out of stock strategy.
240     *
241     * @param Product $product
242     * @param int $out_of_stock
243     */
244    public function processProductOutOfStock(Product $product, $out_of_stock)
245    {
246        StockAvailable::setProductOutOfStock((int) $product->id, (int) $out_of_stock);
247    }
248
249    /**
250     * @param Product $product
251     * @param string $location
252     */
253    public function processLocation(Product $product, $location)
254    {
255        StockAvailable::setLocation($product->id, $location);
256    }
257
258    /**
259     * Set if a product depends on stock (ASM). For a product or a combination.
260     *
261     * Does work only in Advanced stock management.
262     *
263     * @param Product $product
264     * @param bool $dependsOnStock
265     * @param int $forAttributeId
266     */
267    public function processDependsOnStock(Product $product, $dependsOnStock, $forAttributeId = 0)
268    {
269        StockAvailable::setProductDependsOnStock((int) $product->id, $dependsOnStock, null, $forAttributeId);
270    }
271
272    /**
273     * Add/Update a SpecificPrice object.
274     *
275     * @param int $id_product
276     * @param array $specificPriceValues the posted values
277     * @param int|null $idSpecificPrice if this is an update of an existing specific price, null else
278     *
279     * @return AdminProductsController|array
280     */
281    public function processProductSpecificPrice($id_product, $specificPriceValues, $idSpecificPrice = null)
282    {
283        // ---- data formatting ----
284        $id_product_attribute = $specificPriceValues['sp_id_product_attribute'] ?? 0;
285        $id_shop = $specificPriceValues['sp_id_shop'] ? $specificPriceValues['sp_id_shop'] : 0;
286        $id_currency = $specificPriceValues['sp_id_currency'] ? $specificPriceValues['sp_id_currency'] : 0;
287        $id_country = $specificPriceValues['sp_id_country'] ? $specificPriceValues['sp_id_country'] : 0;
288        $id_group = $specificPriceValues['sp_id_group'] ? $specificPriceValues['sp_id_group'] : 0;
289        $id_customer = !empty($specificPriceValues['sp_id_customer']['data']) ? $specificPriceValues['sp_id_customer']['data'][0] : 0;
290        $price = isset($specificPriceValues['leave_bprice']) ? '-1' : $this->floatParser->fromString($specificPriceValues['sp_price']);
291        $from_quantity = $specificPriceValues['sp_from_quantity'];
292        $reduction = $this->floatParser->fromString($specificPriceValues['sp_reduction']);
293        $reduction_tax = $specificPriceValues['sp_reduction_tax'];
294        $reduction_type = !$reduction ? 'amount' : $specificPriceValues['sp_reduction_type'];
295        $reduction_type = $reduction_type == '-' ? 'amount' : $reduction_type;
296        $from = $specificPriceValues['sp_from'];
297        if (!$from) {
298            $from = '0000-00-00 00:00:00';
299        }
300        $to = $specificPriceValues['sp_to'];
301        if (!$to) {
302            $to = '0000-00-00 00:00:00';
303        }
304        $isThisAnUpdate = (null !== $idSpecificPrice);
305
306        // ---- validation ----
307        if (($price == '-1') && ((float) $reduction == '0')) {
308            $this->errors[] = $this->translator->trans('No reduction value has been submitted', [], 'Admin.Catalog.Notification');
309        } elseif ($to != '0000-00-00 00:00:00' && strtotime($to) < strtotime($from)) {
310            $this->errors[] = $this->translator->trans('Invalid date range', [], 'Admin.Catalog.Notification');
311        } elseif ($reduction_type == 'percentage' && ((float) $reduction <= 0 || (float) $reduction > 100)) {
312            $this->errors[] = $this->translator->trans('Submitted reduction value (0-100) is out-of-range', [], 'Admin.Catalog.Notification');
313        }
314        $validationResult = $this->validateSpecificPrice(
315            $id_product,
316            $id_shop,
317            $id_currency,
318            $id_country,
319            $id_group,
320            $id_customer,
321            $price,
322            $from_quantity,
323            $reduction,
324            $reduction_type,
325            $from,
326            $to,
327            $id_product_attribute,
328            $isThisAnUpdate
329        );
330
331        if (false === $validationResult || count($this->errors)) {
332            return $this->errors;
333        }
334
335        // ---- data modification ----
336        if ($isThisAnUpdate) {
337            $specificPrice = new SpecificPrice($idSpecificPrice);
338        } else {
339            $specificPrice = new SpecificPrice();
340        }
341
342        $specificPrice->id_product = (int) $id_product;
343        $specificPrice->id_product_attribute = (int) $id_product_attribute;
344        $specificPrice->id_shop = (int) $id_shop;
345        $specificPrice->id_currency = (int) ($id_currency);
346        $specificPrice->id_country = (int) ($id_country);
347        $specificPrice->id_group = (int) ($id_group);
348        $specificPrice->id_customer = (int) $id_customer;
349        $specificPrice->price = (float) ($price);
350        $specificPrice->from_quantity = (int) ($from_quantity);
351        $specificPrice->reduction = (float) ($reduction_type == 'percentage' ? $reduction / 100 : $reduction);
352        $specificPrice->reduction_tax = $reduction_tax;
353        $specificPrice->reduction_type = $reduction_type;
354        $specificPrice->from = $from;
355        $specificPrice->to = $to;
356
357        if ($isThisAnUpdate) {
358            $dataSavingResult = $specificPrice->save();
359        } else {
360            $dataSavingResult = $specificPrice->add();
361        }
362
363        if (false === $dataSavingResult) {
364            $this->errors[] = $this->translator->trans('An error occurred while updating the specific price.', [], 'Admin.Catalog.Notification');
365        }
366
367        return $this->errors;
368    }
369
370    /**
371     * Validate a specific price.
372     */
373    private function validateSpecificPrice(
374        $id_product,
375        $id_shop,
376        $id_currency,
377        $id_country,
378        $id_group,
379        $id_customer,
380        $price,
381        $from_quantity,
382        $reduction,
383        $reduction_type,
384        $from,
385        $to,
386        $id_combination = 0,
387        $isThisAnUpdate = false
388    ) {
389        if (!Validate::isUnsignedId($id_shop) || !Validate::isUnsignedId($id_currency) || !Validate::isUnsignedId($id_country) || !Validate::isUnsignedId($id_group) || !Validate::isUnsignedId($id_customer)) {
390            $this->errors[] = 'Wrong IDs';
391        } elseif ((!isset($price) && !isset($reduction)) || (isset($price) && !Validate::isNegativePrice($price)) || (isset($reduction) && !Validate::isPrice($reduction))) {
392            $this->errors[] = 'Invalid price/discount amount';
393        } elseif (!Validate::isUnsignedInt($from_quantity)) {
394            $this->errors[] = 'Invalid quantity';
395        } elseif ($reduction && !Validate::isReductionType($reduction_type)) {
396            $this->errors[] = 'Please select a discount type (amount or percentage).';
397        } elseif ($from && $to && (!Validate::isDateFormat($from) || !Validate::isDateFormat($to))) {
398            $this->errors[] = 'The from/to date is invalid.';
399        } elseif (!$isThisAnUpdate && SpecificPrice::exists((int) $id_product, $id_combination, $id_shop, $id_group, $id_country, $id_currency, $id_customer, $from_quantity, $from, $to, false)) {
400            $this->errors[] = 'A specific price already exists for these parameters.';
401        } else {
402            return true;
403        }
404
405        return false;
406    }
407
408    /**
409     * Get specific prices list for a product.
410     *
411     * @param object $product
412     * @param object $defaultCurrency
413     * @param array $shops Available shops
414     * @param array $currencies Available currencies
415     * @param array $countries Available countries
416     * @param array $groups Available users groups
417     *
418     * @return array
419     */
420    public function getSpecificPricesList($product, $defaultCurrency, $shops, $currencies, $countries, $groups)
421    {
422        $content = [];
423        $specific_prices = array_merge(
424            SpecificPrice::getByProductId((int) $product->id),
425            SpecificPrice::getByProductId(0)
426        );
427
428        $tmp = [];
429        foreach ($shops as $shop) {
430            $tmp[$shop['id_shop']] = $shop;
431        }
432        $shops = $tmp;
433        $tmp = [];
434        foreach ($currencies as $currency) {
435            $tmp[$currency['id_currency']] = $currency;
436        }
437        $currencies = $tmp;
438
439        $tmp = [];
440        foreach ($countries as $country) {
441            $tmp[$country['id_country']] = $country;
442        }
443        $countries = $tmp;
444
445        $tmp = [];
446        foreach ($groups as $group) {
447            $tmp[$group['id_group']] = $group;
448        }
449        $groups = $tmp;
450
451        if (is_array($specific_prices) && count($specific_prices)) {
452            foreach ($specific_prices as $specific_price) {
453                $id_currency = $specific_price['id_currency'] ? $specific_price['id_currency'] : $defaultCurrency->id;
454                if (!isset($currencies[$id_currency])) {
455                    continue;
456                }
457
458                $current_specific_currency = $currencies[$id_currency];
459                if ($specific_price['reduction_type'] == 'percentage') {
460                    $impact = '- ' . ($specific_price['reduction'] * 100) . ' %';
461                } elseif ($specific_price['reduction'] > 0) {
462                    $impact = '- ' . $this->locale->formatPrice($specific_price['reduction'], $current_specific_currency['iso_code']) . ' ';
463                    if ($specific_price['reduction_tax']) {
464                        $impact .= '(' . $this->translator->trans('Tax incl.', [], 'Admin.Global') . ')';
465                    } else {
466                        $impact .= '(' . $this->translator->trans('Tax excl.', [], 'Admin.Global') . ')';
467                    }
468                } else {
469                    $impact = '--';
470                }
471
472                if ($specific_price['from'] == '0000-00-00 00:00:00' && $specific_price['to'] == '0000-00-00 00:00:00') {
473                    $period = $this->translator->trans('Unlimited', [], 'Admin.Global');
474                } else {
475                    $period = $this->translator->trans('From', [], 'Admin.Global') . ' ' . ($specific_price['from'] != '0000-00-00 00:00:00' ? $specific_price['from'] : '0000-00-00 00:00:00') . '<br />' . $this->translator->trans('to', [], 'Admin.Global') . ' ' . ($specific_price['to'] != '0000-00-00 00:00:00' ? $specific_price['to'] : '0000-00-00 00:00:00');
476                }
477                if ($specific_price['id_product_attribute']) {
478                    $combination = new Combination((int) $specific_price['id_product_attribute']);
479                    $attributes = $combination->getAttributesName(1);
480                    $attributes_name = '';
481                    foreach ($attributes as $attribute) {
482                        $attributes_name .= $attribute['name'] . ' - ';
483                    }
484                    $attributes_name = rtrim($attributes_name, ' - ');
485                } else {
486                    $attributes_name = $this->translator->trans('All combinations', [], 'Admin.Catalog.Feature');
487                }
488
489                $rule = new SpecificPriceRule((int) $specific_price['id_specific_price_rule']);
490                $rule_name = ($rule->id ? $rule->name : '--');
491
492                if ($specific_price['id_customer']) {
493                    $customer = new Customer((int) $specific_price['id_customer']);
494                    if (Validate::isLoadedObject($customer)) {
495                        $customer_full_name = $customer->firstname . ' ' . $customer->lastname;
496                    }
497                    unset($customer);
498                }
499
500                if (!$specific_price['id_shop'] || in_array($specific_price['id_shop'], Shop::getContextListShopID())) {
501                    $can_delete_specific_prices = true;
502                    if (Shop::isFeatureActive()) {
503                        $can_delete_specific_prices = (count($this->employeeAssociatedShops) > 1 && !$specific_price['id_shop']) || $specific_price['id_shop'];
504                    }
505
506                    $price = Tools::ps_round($specific_price['price'], 2);
507                    $fixed_price = (($price == Tools::ps_round($product->price, 2) && $current_specific_currency['id_currency'] == $defaultCurrency->id) || $specific_price['price'] == -1) ? '--' : $this->locale->formatPrice($price, $current_specific_currency['iso_code']);
508
509                    $content[] = [
510                        'id_specific_price' => $specific_price['id_specific_price'],
511                        'id_product' => $product->id,
512                        'rule_name' => $rule_name,
513                        'attributes_name' => $attributes_name,
514                        'shop' => ($specific_price['id_shop'] ? $shops[$specific_price['id_shop']]['name'] : $this->translator->trans('All shops', [], 'Admin.Global')),
515                        'currency' => ($specific_price['id_currency'] ? $currencies[$specific_price['id_currency']]['name'] : $this->translator->trans('All currencies', [], 'Admin.Global')),
516                        'country' => ($specific_price['id_country'] ? $countries[$specific_price['id_country']]['name'] : $this->translator->trans('All countries', [], 'Admin.Global')),
517                        'group' => ($specific_price['id_group'] ? $groups[$specific_price['id_group']]['name'] : $this->translator->trans('All groups', [], 'Admin.Global')),
518                        'customer' => (isset($customer_full_name) ? $customer_full_name : $this->translator->trans('All customers', [], 'Admin.Global')),
519                        'fixed_price' => $fixed_price,
520                        'impact' => $impact,
521                        'period' => $period,
522                        'from_quantity' => $specific_price['from_quantity'],
523                        'can_delete' => (!$rule->id && $can_delete_specific_prices) ? true : false,
524                        'can_edit' => (!$rule->id && $can_delete_specific_prices) ? true : false,
525                    ];
526
527                    unset($customer_full_name);
528                }
529            }
530        }
531
532        return $content;
533    }
534
535    /**
536     * @param int $id
537     *
538     * @return SpecificPrice
539     *
540     * @throws EntityNotFoundException
541     */
542    public function getSpecificPriceDataById($id)
543    {
544        $price = new SpecificPrice($id);
545        if (null === $price->id) {
546            throw new EntityNotFoundException(sprintf('Cannot find specific price with id %d', $id));
547        }
548
549        return $price;
550    }
551
552    /**
553     * Delete a specific price.
554     *
555     * @param int $id_specific_price
556     *
557     * @return array error & status
558     */
559    public function deleteSpecificPrice($id_specific_price)
560    {
561        if (!$id_specific_price || !Validate::isUnsignedId($id_specific_price)) {
562            $error = $this->translator->trans('The specific price ID is invalid.', [], 'Admin.Catalog.Notification');
563        } else {
564            $specificPrice = new SpecificPrice((int) $id_specific_price);
565            if (!$specificPrice->delete()) {
566                $error = $this->translator->trans('An error occurred while attempting to delete the specific price.', [], 'Admin.Catalog.Notification');
567            }
568        }
569
570        if (isset($error)) {
571            return [
572                'status' => 'error',
573                'message' => $error,
574            ];
575        }
576
577        return [
578            'status' => 'ok',
579            'message' => $this->translator->trans('Successful deletion', [], 'Admin.Notifications.Success'),
580        ];
581    }
582
583    /**
584     * Get price priority.
585     *
586     * @param int|null $idProduct
587     *
588     * @return array
589     */
590    public function getPricePriority($idProduct = null)
591    {
592        if (!$idProduct) {
593            return [
594                0 => 'id_shop',
595                1 => 'id_currency',
596                2 => 'id_country',
597                3 => 'id_group',
598            ];
599        }
600
601        $specific_price_priorities = SpecificPrice::getPriority((int) $idProduct);
602
603        // Not use id_customer
604        if ($specific_price_priorities[0] == 'id_customer') {
605            unset($specific_price_priorities[0]);
606        }
607
608        return array_values($specific_price_priorities);
609    }
610
611    /**
612     * Process customization collection.
613     *
614     * @param object $product
615     * @param array $data
616     *
617     * @return array<int, int>
618     */
619    public function processProductCustomization($product, $data)
620    {
621        $customization_ids = [];
622        if ($data) {
623            foreach ($data as $customization) {
624                $customization_ids[] = (int) $customization['id_customization_field'];
625            }
626        }
627
628        $shopList = Shop::getContextListShopID();
629
630        /* Update the customization fields to be deleted in the next step if not used */
631        $product->softDeleteCustomizationFields($customization_ids);
632
633        $usedCustomizationIds = $product->getUsedCustomizationFieldsIds();
634        $usedCustomizationIds = array_column($usedCustomizationIds, 'index');
635        $usedCustomizationIds = array_map('intval', $usedCustomizationIds);
636        $usedCustomizationIds = array_unique(array_merge($usedCustomizationIds, $customization_ids), SORT_REGULAR);
637
638        //remove customization field langs for current context shops
639        $productCustomization = $product->getCustomizationFieldIds();
640        $toDeleteCustomizationIds = [];
641        foreach ($productCustomization as $customizationFiled) {
642            if (!in_array((int) $customizationFiled['id_customization_field'], $usedCustomizationIds)) {
643                $toDeleteCustomizationIds[] = (int) $customizationFiled['id_customization_field'];
644            }
645            //if the customization_field is still in use, only delete the current context shops langs,
646            if (in_array((int) $customizationFiled['id_customization_field'], $customization_ids)) {
647                Customization::deleteCustomizationFieldLangByShop($customizationFiled['id_customization_field'], $shopList);
648            }
649        }
650
651        //remove unused customization for the product
652        $product->deleteUnusedCustomizationFields($toDeleteCustomizationIds);
653
654        //create new customizations
655        $countFieldText = 0;
656        $countFieldFile = 0;
657        $productCustomizableValue = 0;
658        $hasRequiredField = false;
659
660        $new_customization_fields_ids = [];
661
662        if ($data) {
663            foreach ($data as $key => $customization) {
664                if ($customization['require']) {
665                    $hasRequiredField = true;
666                }
667
668                //create label
669                if (isset($customization['id_customization_field'])) {
670                    $id_customization_field = (int) $customization['id_customization_field'];
671                    Db::getInstance()->execute('UPDATE `' . _DB_PREFIX_ . 'customization_field`
672					SET `required` = ' . ($customization['require'] ? 1 : 0) . ', `type` = ' . (int) $customization['type'] . '
673					WHERE `id_customization_field` = ' . $id_customization_field);
674                } else {
675                    Db::getInstance()->execute(
676                        'INSERT INTO `' . _DB_PREFIX_ . 'customization_field` (`id_product`, `type`, `required`)
677                    	VALUES ('
678                            . (int) $product->id . ', '
679                            . (int) $customization['type'] . ', '
680                            . ($customization['require'] ? 1 : 0)
681                        . ')'
682                    );
683                    $id_customization_field = (int) Db::getInstance()->Insert_ID();
684                }
685
686                $new_customization_fields_ids[$key] = $id_customization_field;
687
688                // Create multilingual label name
689                $langValues = '';
690                foreach (Language::getLanguages() as $language) {
691                    $name = $customization['label'][$language['id_lang']];
692                    foreach ($shopList as $id_shop) {
693                        $langValues .= '('
694                            . (int) $id_customization_field . ', '
695                            . (int) $language['id_lang'] . ', '
696                            . (int) $id_shop . ',\''
697                            . pSQL($name)
698                            . '\'), ';
699                    }
700                }
701                Db::getInstance()->execute(
702                    'INSERT INTO `' . _DB_PREFIX_ . 'customization_field_lang` (`id_customization_field`, `id_lang`, `id_shop`, `name`) VALUES '
703                    . rtrim(
704                        $langValues,
705                        ', '
706                    )
707                );
708
709                if ($customization['type'] == Product::CUSTOMIZE_FILE) {
710                    ++$countFieldFile;
711                } else {
712                    ++$countFieldText;
713                }
714            }
715
716            $productCustomizableValue = $hasRequiredField ? 2 : 1;
717        }
718
719        //update product count fields labels
720        Db::getInstance()->execute('UPDATE `' . _DB_PREFIX_ . 'product` SET `customizable` = ' . $productCustomizableValue . ', `uploadable_files` = ' . (int) $countFieldFile . ', `text_fields` = ' . (int) $countFieldText . ' WHERE `id_product` = ' . (int) $product->id);
721
722        //update product_shop count fields labels
723        ObjectModel::updateMultishopTable('product', [
724            'customizable' => $productCustomizableValue,
725            'uploadable_files' => (int) $countFieldFile,
726            'text_fields' => (int) $countFieldText,
727        ], 'a.id_product = ' . (int) $product->id);
728
729        Configuration::updateGlobalValue('PS_CUSTOMIZATION_FEATURE_ACTIVE', '1');
730
731        return $new_customization_fields_ids;
732    }
733
734    /**
735     * Update product download.
736     *
737     * @param object $product
738     * @param array $data
739     *
740     * @return ProductDownload
741     */
742    public function updateDownloadProduct($product, $data)
743    {
744        $id_product_download = ProductDownload::getIdFromIdProduct((int) $product->id, false);
745        $download = new ProductDownload($id_product_download ? $id_product_download : null);
746
747        if ((int) $data['is_virtual_file'] == 1) {
748            $fileName = null;
749            $file = $data['file'];
750
751            if (!empty($file)) {
752                $fileName = ProductDownload::getNewFilename();
753                $file->move(_PS_DOWNLOAD_DIR_, $fileName);
754            }
755
756            $product->setDefaultAttribute(0); //reset cache_default_attribute
757
758            $download->id_product = (int) $product->id;
759            $download->display_filename = $data['name'];
760            $download->filename = $fileName ? $fileName : $download->filename;
761            $download->date_add = date('Y-m-d H:i:s');
762            $download->date_expiration = $data['expiration_date'] ? $data['expiration_date'] . ' 23:59:59' : '';
763            $download->nb_days_accessible = (int) $data['nb_days'];
764            $download->nb_downloadable = (int) $data['nb_downloadable'];
765            $download->active = true;
766            $download->is_shareable = false;
767
768            if (!$id_product_download) {
769                $download->save();
770            } else {
771                $download->update();
772            }
773        } else {
774            if (!empty($id_product_download)) {
775                $download->date_expiration = date('Y-m-d H:i:s', time() - 1);
776                $download->active = false;
777                $download->update();
778            }
779        }
780
781        return $download;
782    }
783
784    /**
785     * Delete file from a virtual product.
786     *
787     * @param object $product
788     */
789    public function processDeleteVirtualProductFile($product)
790    {
791        $id_product_download = ProductDownload::getIdFromIdProduct((int) $product->id, false);
792        $download = new ProductDownload($id_product_download ? $id_product_download : null);
793
794        if (!empty($download->filename)) {
795            unlink(_PS_DOWNLOAD_DIR_ . $download->filename);
796            Db::getInstance()->execute('UPDATE `' . _DB_PREFIX_ . 'product_download` SET filename = "" WHERE `id_product_download` = ' . (int) $download->id);
797        }
798    }
799
800    /**
801     * Delete a virtual product.
802     *
803     * @param object $product
804     */
805    public function processDeleteVirtualProduct($product)
806    {
807        $id_product_download = ProductDownload::getIdFromIdProduct((int) $product->id, false);
808        $download = new ProductDownload($id_product_download ? $id_product_download : null);
809        if (Validate::isLoadedObject($download)) {
810            $download->delete(true);
811        }
812    }
813
814    /**
815     * Add attachement file.
816     *
817     * @param object $product
818     * @param array $data
819     * @param array $locales
820     *
821     * @return object|null Attachement
822     */
823    public function processAddAttachment($product, $data, $locales)
824    {
825        $attachment = null;
826        $file = $data['file'];
827        if (!empty($file)) {
828            $fileName = sha1(microtime());
829            $attachment = new Attachment();
830
831            foreach ($locales as $locale) {
832                $attachment->name[(int) $locale['id_lang']] = $data['name'];
833                $attachment->description[(int) $locale['id_lang']] = $data['description'];
834            }
835
836            $attachment->file = $fileName;
837            $attachment->mime = $file->getMimeType();
838            $attachment->file_name = $file->getClientOriginalName();
839
840            $file->move(_PS_DOWNLOAD_DIR_, $fileName);
841
842            if ($attachment->add()) {
843                $attachment->attachProduct($product->id);
844            }
845        }
846
847        return $attachment;
848    }
849
850    /**
851     * Process product attachments.
852     *
853     * @param object $product
854     * @param array $data
855     */
856    public function processAttachments($product, $data)
857    {
858        Attachment::attachToProduct($product->id, $data);
859    }
860
861    /**
862     * Update images positions.
863     *
864     * @param array $data Indexed array with id product/position
865     */
866    public function ajaxProcessUpdateImagePosition($data)
867    {
868        foreach ($data as $id => $position) {
869            $img = new Image((int) $id);
870            $img->position = (int) $position;
871            $img->update();
872        }
873    }
874
875    /**
876     * Update image legend and cover.
877     *
878     * @param int $idImage
879     * @param array $data
880     *
881     * @return object image
882     */
883    public function ajaxProcessUpdateImage($idImage, $data)
884    {
885        $img = new Image((int) $idImage);
886        if ($data['cover']) {
887            Image::deleteCover((int) $img->id_product);
888            $img->cover = true;
889        }
890        $img->legend = $data['legend'];
891        $img->update();
892
893        return $img;
894    }
895
896    /**
897     * Generate preview URL.
898     *
899     * @param object $product
900     * @param bool $preview
901     *
902     * @return string|bool Preview url
903     */
904    public function getPreviewUrl($product, $preview = true)
905    {
906        $context = Context::getContext();
907        $id_lang = Configuration::get('PS_LANG_DEFAULT', null, null, $context->shop->id);
908
909        if (!ShopUrl::getMainShopDomain()) {
910            return false;
911        }
912
913        $is_rewrite_active = (bool) Configuration::get('PS_REWRITING_SETTINGS');
914        $preview_url = $context->link->getProductLink(
915            $product,
916            $product->link_rewrite[$context->language->id],
917            Category::getLinkRewrite($product->id_category_default, $context->language->id),
918            null,
919            $id_lang,
920            (int) $context->shop->id,
921            0,
922            $is_rewrite_active
923        );
924
925        if (!$product->active && $preview) {
926            $preview_url = $this->getPreviewUrlDeactivate($preview_url);
927        }
928
929        return $preview_url;
930    }
931
932    /**
933     * Generate preview URL deactivate.
934     *
935     * @param string $preview_url
936     *
937     * @return string preview url deactivate
938     */
939    public function getPreviewUrlDeactivate($preview_url)
940    {
941        $context = Context::getContext();
942        $token = Tools::getAdminTokenLite('AdminProducts');
943
944        $admin_dir = dirname($_SERVER['PHP_SELF']);
945        $admin_dir = substr($admin_dir, strrpos($admin_dir, '/') + 1);
946        $preview_url_deactivate = $preview_url . ((strpos($preview_url, '?') === false) ? '?' : '&') . 'adtoken=' . $token . '&ad=' . $admin_dir . '&id_employee=' . (int) $context->employee->id . '&preview=1';
947
948        return $preview_url_deactivate;
949    }
950
951    /**
952     * Generate preview URL.
953     *
954     * @param int $productId
955     *
956     * @return string preview url
957     */
958    public function getPreviewUrlFromId($productId)
959    {
960        $product = new Product($productId, false);
961
962        return $this->getPreviewUrl($product);
963    }
964}
965