module_title = _t('listings', 'Listings'); # Dynprops $this->attachComponent('dynprops', function () { return $this->dp(); }); # ItemServices Svc::registerServiceManager(ItemServices::KEY, ItemServices::class); } public function onNewRequest($request) { parent::onNewRequest($request); $this->cache = []; } /** * Системные настройки модуля * @param array $options @ref * @return string */ public function settingsSystem(array &$options = []): string { return $this->template('admin/settings.sys', ['options' => &$options]); } /** * Список шаблонов уведомлений * @return array */ public function sendmailTemplates(): array { $templates = [ 'listings_item_activate' => [ 'title' => _t('listings', 'Listings: Listing Activation'), 'description' => _t('listings', 'Notification sent to an unregistered user after adding a listing'), 'vars' => [ '{name}' => _t('users', 'Name'), '{email}' => _t('', 'Email'), '{activate_link}' => _t('listings', 'Listing Activation Link'), ], 'priority' => 10, 'enotify' => 0, # всегда 'group' => 'listings', 'force_verified' => true, ], 'listings_item_sendfriend' => [ 'title' => _t('listings', 'Listings: Send to a Friend'), 'description' => _t('listings', 'Notification sent to the specified email address'), 'vars' => [ '{item_id}' => _t('listings', 'Listing ID'), '{item_title}' => _t('listings', 'Listing Title'), '{item_link}' => _t('listings', 'Listing Link'), ], 'priority' => 11, 'enotify' => -1, 'group' => 'listings', ], 'listings_item_deleted' => [ 'title' => _t('listings', 'Listings: Deleting Listing'), 'description' => _t('listings', 'Notification sent to the user in case if the listing is deleted by the moderator'), 'vars' => [ '{name}' => _t('users', 'Name'), '{email}' => _t('', 'Email'), '{item_id}' => _t('listings', 'Listing ID'), '{item_title}' => _t('listings', 'Listing Title'), '{item_link}' => _t('listings', 'Listing Link'), ], 'priority' => 14, 'enotify' => 0, # всегда 'group' => 'listings', ], 'listings_item_photo_deleted' => [ 'title' => _t('listings', 'Listings: Deleting Listing Photo'), 'description' => _t('listings', 'Notification sent to the user in case if the photo of the listing is deleted by the moderator'), 'vars' => [ '{name}' => _t('users', 'Name'), '{email}' => _t('', 'Email'), '{item_id}' => _t('listings', 'Listing ID'), '{item_title}' => _t('listings', 'Listing Title'), '{item_link}' => _t('listings', 'Listing Link'), ], 'priority' => 15, 'enotify' => 0, # всегда 'group' => 'listings', ], 'listings_item_blocked' => [ 'title' => _t('listings', 'Listings: Listing Blocking'), 'description' => _t('listings', 'Notification sent to the user in case of blocking the announcement by the moderator'), 'vars' => [ '{name}' => _t('users', 'Name'), '{email}' => _t('', 'Email'), '{item_id}' => _t('listings', 'Listing ID'), '{item_title}' => _t('listings', 'Listing Title'), '{item_link}' => _t('listings', 'Listing Link'), '{blocked_reason}' => _t('listings', 'Blocking Reason'), ], 'priority' => 16, 'enotify' => 0, # всегда 'group' => 'listings', ], 'listings_item_unpublicated_soon' => [ 'title' => _t('listings', 'Listings: Notification of the End of Publishing for the Listing'), 'description' => _t('listings', 'Notification sent to the user with the information of the completion of his listing publishing'), 'vars' => [ '{name}' => _t('users', 'Name'), '{item_id}' => _t('listings', 'Listing ID'), '{item_title}' => _t('listings', 'Listing Title'), '{item_link}' => _t('listings', 'Listing Link'), '{days_in}' => _t('listings', 'Number of Days Until Publishing Ends'), '{publicate_link}' => _t('listings', 'Prolong Publishing Link'), '{edit_link} ' => _t('listings', 'Edit Listings Link'), ], 'priority' => 17, 'enotify' => Users::ENOTIFY_NEWS, 'group' => 'listings', ], 'listings_item_unpublicated_soon_group' => [ 'title' => _t('listings', 'Listings: Notification of the Publishing of Several Listings'), 'description' => _t('listings', 'Notification sent to the user with the information of the completion of his listings publishing'), 'vars' => [ '{name}' => _t('users', 'Name'), '{count}' => _t('listings', 'Number of Listings') . '
' . _t('listings', 'e.g. 10') . '
', '{count_items}' => _t('listings', 'Number of Listings') . '
' . _t('listings', 'e.g. 10 listings') . '
', '{days_in}' => _t('listings', 'Number of Days Until Publishing Ends'), '{publicate_link}' => _t('listings', 'Prolong Publishing Link'), ], 'priority' => 18, 'enotify' => Users::ENOTIFY_NEWS, 'group' => 'listings', ], ]; if ($this->commentsEnabled()) { $templates['listings_item_comment'] = [ 'title' => _t('listings', 'Listings: New Comment to the Listing'), 'description' => _t('listings', 'Notification sent to the user in case of a new comment to the listing'), 'vars' => [ '{name}' => _t('users', 'Name'), '{email}' => _t('', 'Email'), '{item_id}' => _t('listings', 'Listing ID'), '{item_title}' => _t('listings', 'Listing Title'), '{item_link}' => _t('listings', 'Listing Link'), ], 'priority' => 16, 'enotify' => Users::ENOTIFY_LISTINGS_COMMENTS, 'group' => 'listings', Sendmail::CHANNEL_SMS => true, ]; } Sendmail::addTemplateGroup('listings', _t('listings', 'Listings'), 1); if (bff::servicesEnabled('listings')) { foreach ($this->itemServices()->getNotificationsMacro(true) as $key => $title) { foreach (['listings_item_unpublicated_soon'] as $templateKey) { if (isset($templates[$templateKey])) { $templates[$templateKey]['vars']['{' . $key . '}'] = $title; } } } } return $templates; } /** * Формирование URL * @param string $key ключ * @param array $params доп. параметры * @param bool $dynamic динамическая ссылка * @return string */ public function url(string $key, array $params = [], $dynamic = false): string { return $this->router->url('listings-' . $key, $params, ['dynamic' => $dynamic, 'module' => 'listings']); } /** * Включен фильтр по региону * @return bool */ public function geoFilterEnabled() { return $this->app->filter('listings.geo.filter.enabled', ! Geo::filterDisabled()); } /** * Отображать ссылки на категории без объявлений в фильтре поиска * @return bool */ public function seoSearchFilterShowEmptyCats() { return $this->config('listings.search.filter.cats.empty', false, TYPE_BOOL); } /** * Включена ли премодерация объявлений * @return bool */ public function premoderation() { return $this->config('listings.premoderation', true, TYPE_BOOL); } /** * Публикация объявления доступна только авторизованным пользователям * @return bool */ public function publisherAuth() { return $this->config('listings.publisher.auth', false, TYPE_BOOL); } /** * Включена ли премодерация объявлений при редактировании * @return bool */ public function premoderationEdit() { return $this->premoderation() && $this->config('listings.premoderation.edit', false, TYPE_BOOL); } /** * Получение настройки: доступный тип пользователя публикующего объявление * @param array|string|null $type проверяемый тип * @return mixed */ public function publisher($type = null) { $sys = $this->config('listings.publisher', static::PUBLISHER_USER_OR_COMPANY, TYPE_NOTAGS); if (!bff::moduleExists('business') && ($sys === static::PUBLISHER_COMPANY || $sys === static::PUBLISHER_USER_OR_COMPANY)) { $sys = static::PUBLISHER_USER; } if (empty($type)) { return $sys; } return (is_array($type) ? in_array($sys, $type, true) : ($type === $sys)); } /** * Варианты списка объявлений * @return array */ public function itemsSearchListTypes() { $images = [ 'sizes' => [ItemImages::szSmall, ItemImages::szMedium], 'extensions' => ['svg'], ]; $types = $this->app->filter('listings.search.list.type.list', [ static::LIST_TYPE_LIST => [ 'key' => 'list', 'title' => _t('listings', 'List'), 't' => _t('search', 'List'), 'i' => 'fa fa-th-list', 'is_map' => false, 'image' => $images, ], static::LIST_TYPE_GALLERY => [ 'key' => 'gallery', 'title' => _t('listings', 'Gallery'), 't' => _t('search', 'Gallery'), 'i' => 'fa fa-th', 'is_map' => false, 'image' => $images, ], static::LIST_TYPE_MAP => [ 'key' => 'map', 'title' => _t('listings', 'Map'), 't' => _t('search', 'On Map'), 'i' => 'fa fa-map-marker', 'is_map' => true, 'image' => $images, ], ], $images); func::sortByPriority($types, 'priority', true); foreach ($types as $k => &$v) { $v['id'] = $k; $v['a'] = 0; # active } unset($v); return $types; } /** * Причины блокировки для модератора * @param string|null $lang * @return array */ public function blockedReasons(?string $lang = null): array { return Options::getOptions('listings-item-block-reasons', $lang ?? $this->locale->current()); } /** * Использовать мультиязычный контент для объявлений * @return bool */ public function translate(): bool { return $this->config('listings.translate', '', TYPE_NOTAGS) !== ''; } /** * Массив соответствия полей для генерации автозаголовков для таблиц категорий и объявлений * @return array */ public function autoTplFields() { return [ 'tpl_title_list' => 'title_list', 'tpl_title_view' => 'title', ]; } /** * Проверка типа публикующего пользователя * @param int $companyId ID компании пользователя, публикующей объявление * @param string|bool $companyUseField название поля, отвечающего за использование компании для публикации * @return int итоговый ID компании, закрепляемый за публикуемым объявлением */ public function publisherCheck($companyId, $companyUseField = 'company') { switch ($this->publisher()) { # только пользователь (добавление объявлений доступно сразу, объявления размещаются только от "частного лица") case static::PUBLISHER_USER: { return 0; } break; # только компания (добавление объявлений доступно после открытия компании, только от "компании") case static::PUBLISHER_COMPANY: { if (!$companyId) { if ($this->isAdminPanel()) { $this->errors->set(_t('listings', 'The specified user did not create a company'), 'email'); } else { $this->errors->reloadPage(); } return 0; } } break; # пользователь и компания (добавление объявлений доступно сразу только от "частного лица", после открытия компании - объявления размещаются только от "компании") case static::PUBLISHER_USER_TO_COMPANY: { return ($companyId && bff::moduleExists('business') ? $companyId : 0); } break; # пользователь или компания (добавление объявлений доступно сразу только от "частного лица", # после открытия компании - объявления размещаются от "частного лица" или "компании") case static::PUBLISHER_USER_OR_COMPANY: { if (is_bool($companyUseField)) { $byCompany = $companyUseField; } else { $byCompany = $this->input->postget($companyUseField, TYPE_BOOL); } if (!$byCompany || !$companyId || !bff::moduleExists('business')) { return 0; } } break; } return $companyId; } /** * Инициализация компонента работы с дин. свойствами * @return ItemDynprops */ public function dp() { return ItemDynprops::i(); } /** * Поиск ближайшего родителя с заполненными шаблонами * @param int $categoryID категория * @param array $fields для каких полей искать родителя * @param array $data @ref данные о категории (заполняем значение шаблонов) * @param bool $includingSelf анализировать и указанную категорию или только родителей * @return int ID найденного родителя */ public function catNearestParent(int $categoryID, array $fields, array &$data, bool $includingSelf = true) { if (empty($categoryID) || empty($fields)) { return 0; } # анализировать флаг tpl_title_enabled или нет $isEnabled = true; if (in_array('tpl_descr_list', $fields) && count($fields) == 1) { $isEnabled = false; } if ($includingSelf) { $empty = true; foreach ($fields as $f) { if (! empty($data[$f])) { $empty = false; } } if (! $empty) { if ($isEnabled && empty($data['tpl_title_enabled'])) { return 0; } return $categoryID; } } if ($isEnabled) { $fields[] = 'tpl_title_enabled'; } # найдем родителей if (! in_array('id', $fields)) { $fields[] = 'id'; } $parents = $this->model->catParentsData($categoryID, $fields, ['includingSelf' => $includingSelf]); unset($fields['id'], $fields['tpl_title_enabled']); foreach ($fields as $f) { if (! isset($data[$f])) { $data[$f] = ''; } } $result = 0; if (empty($parents)) { return $result; } $parents = array_reverse($parents); $cur = reset($parents); if ($isEnabled) { if (! $cur['tpl_title_enabled']) { return 0; } $data['tpl_title_enabled'] = 1; } # запишем данные foreach ($parents as $v) { if ($isEnabled && ! $v['tpl_title_enabled']) { continue; } foreach ($fields as $f) { if (! empty($v[$f]) && empty($data[$f])) { $data[$f] = $v[$f]; $result = $v['id']; } } } return $result; } /** * Инициализация компонента изображений объявления * @param int $itemID ID объявления * @param int|null $userID ID пользователя * @return ItemImages */ public function itemImages($itemID = 0, $userID = null) { return ItemImages::i($itemID, $userID); } /** * Допустимое кол-во фотографий * @param bool $max максимальное (true), минимальное (false) * @return int */ public function itemsImagesLimit(bool $max = true) { return $this->config('listings.items.images.limit.' . ($max ? 'max' : 'min'), ($max ? 20 : 4), TYPE_UINT); } /** * Инициализация компонента видео ссылок в объявлении * @return ItemVideo component */ public function itemVideo() { return ItemVideo::i(); } /** * Инициализация компонента ItemComments * @return ItemComments component */ public function itemComments() { return ItemComments::i(); } /** * Включены ли комментарии * @return bool */ public function commentsEnabled() { return $this->config('listings.comments', true, TYPE_BOOL); } /** * Инициализация компонента поиска средствами Sphinx * @return ItemsSearchSphinx component */ public function itemsSearchSphinx() { return ItemsSearchSphinx::i(); } /** * Настройки Sphinx * @param array $settings */ public function sphinxSettings(array $settings) { $this->itemsSearchSphinx()->moduleSettings($settings); } /** * Доступна ли возможность редактирования категории при редактировании ОБ * @return bool */ public function categoryFormEditable() { return $this->config('listings.form.category.edit', false, TYPE_BOOL); } /** * Инициализация компонента обработки иконок категорий объявлений * @param int $categoryID ID категории * @return CategoryIcon component */ public function categoryIcon(int $categoryID = 0) { return CategoryIcon::i($categoryID); } /** * Формирование хлебных крошек * @param int $categoryID ID категории (на основе которой выполняем формирование) * @param string $methodName имя метода * @param array $urlOptions доп. параметры: city, region * @return array */ public function categoryCrumbs(int $categoryID, string $methodName, array $urlOptions = []) { $data = $this->model->catParentsData($categoryID, [ 'id','title','keyword','breadcrumb','landing_url','mtemplate', ]); if (! empty($data)) { foreach ($data as &$v) { # ссылка $v['link'] = $this->url('items.search', $urlOptions + [ 'keyword' => $v['keyword'], 'landing_url' => $v['landing_url'], ]); # активируем $v['active'] = ($v['id'] == $categoryID); # хлебная крошка + макрос {category} if ($methodName === 'view') { if (empty($v['breadcrumb'])) { $v['breadcrumb'] = '{category}'; } $v['breadcrumb'] = strtr($v['breadcrumb'], ['{category}' => $v['title']]); } } unset($v); } else { $data = []; } $searchIndex = ['id' => 0, 'title' => _t('search', 'Listings'), 'breadcrumb' => _t('search', 'Listings {region.in}'), 'active' => empty($data)]; if (! $searchIndex['active']) { $searchIndex['link'] = $this->url('items.search', $urlOptions); } $data = [0 => $searchIndex] + $data; # preserve keys return $data; } /** * Получаем данные о категории для формы добавления/редактирования объявления * @param int $categoryID ID категории * @param array $itemData параметры объявления * @param array $fieldsExtra дополнительно необходимые данные о категории * @param bool $adminPanel контекст админ панели * @return array */ protected function itemFormByCategory(int $categoryID, array $itemData = [], array $fieldsExtra = [], ?bool $adminPanel = null) { if (is_null($adminPanel)) { $adminPanel = $this->isAdminPanel(); } # получаем данные о категории: $fields = [ 'id', 'pid', 'settings', 'subs', 'type_offer_form', 'type_seek_form', 'owner_private_form', 'owner_business_form', 'tpl_title_view', 'tpl_title_enabled', ]; if (!empty($fieldsExtra)) { $fields = array_merge($fields, $fieldsExtra); $fields = array_unique($fields); } $categoryID = $this->model->catToReal($categoryID); $data = $this->model->catDataByFilter(['id' => $categoryID], $fields); if (empty($data)) { return []; } if ($data['subs'] > 0) { # есть подкатегории => формируем список подкатегорий if ($adminPanel) { $data['cats'] = $this->model->catSubcatsData($categoryID, ['sel' => 0, 'empty' => _t('', 'Select')]); } $data['types'] = false; } else { # формируем форму дин. свойств: $data['dp'] = $this->dp()->onForm($categoryID, $itemData, [ 'adminPanel' => $adminPanel, ]); # формируем список типов: if (static::CATS_TYPES_EX) { $data['types'] = $this->model->cattypesByCategory($categoryID); } else { $data['types'] = $this->model->cattypesSimple($data, false); } if (empty($data['types'])) { $data['types'] = false; } $this->catNearestParent($categoryID, ['tpl_title_view'], $data); if (! empty($data['tpl_title_view'])) { $data['tpl_data'] = $this->dp()->prepareItemTemplateData($categoryID, $data['tpl_title_view']); } } $data['edit'] = !empty($itemData['id']); # корректируем необходимые данные объявления $data['item'] = $this->input->clean_array($itemData, [ 'cat_type' => TYPE_UINT, 'price' => TYPE_PRICE, 'price_curr' => TYPE_UINT, 'price_ex' => TYPE_UINT, 'price_ex_mod' => TYPE_UINT, 'owner_type' => TYPE_UINT, 'regions_delivery' => TYPE_BOOL, ]); if (! isset($data['price_sett'])) { $data['price_sett'] = $data['settings']['price']; } if (is_array($data['price'])) { $data['price'] = $data['settings']['price']['enabled']; } $data['price_mod_options'] = $this->itemPrice()->modifiersOptions($data['settings']); if ($adminPanel) { return $data; } if (!$data['edit']) { $data['item']['price'] = ''; } # цена: if (! empty($data['price'])) { $data['price_label'] = ( ! empty($data['settings']['price']['title'][$this->locale->current()]) ? $data['settings']['price']['title'][$this->locale->current()] : _t('item-form', 'Price') ); $data['price_curr_selected'] = ( $data['edit'] && $data['item']['price_curr'] ? $data['item']['price_curr'] : ( ! empty($data['settings']['price']['curr']) ? $data['settings']['price']['curr'] : Currency::id() ) ); } # доступный тип владельца (Частное лицо/Бизнес): $data['owner_types'] = [ static::OWNER_PRIVATE => ( ! empty($data['owner_private_form']) ? $data['owner_private_form'] : _t('listings', 'Individual')), ]; if ($data['owner_business']) { $data['owner_types'][static::OWNER_BUSINESS] = ( ! empty($data['owner_business_form']) ? $data['owner_business_form'] : _t('listings', 'Company') ); } if (empty($data['item']['owner_type'])) { $data['item']['owner_type'] = static::OWNER_PRIVATE; } $data['form'] = $this->template('item/form.cat.form', $data); $data['owner'] = $this->template('item/form.cat.owner', $data); return $data; } /** * Компонент избранных объявлений пользователя * @return ItemsFavorite */ public function favorites() { return ItemsFavorite::i(); } /** * Счетчик избранных объявлений пользователя * @param int $userID ID пользователя или 0 * @return int */ public function favoritesCounter($userID = 0) { return $this->favorites()->getList($userID, true); } /** * Получение списка доступных причин жалобы на объявление * @param string|null $lang * @return array */ public function getItemClaimReasons(?string $lang = null): array { return Options::getOptions('listings-item-claim-reasons', $lang ?? $this->locale->current()); } /** * Формирование текста описания жалобы, с учетом отмеченных причин * @param int $reasons битовое поле причин жалобы * @param string $comment комментарий к жалобе * @return string */ public function getItemClaimText(int $reasons, string $comment): string { $list = $this->getItemClaimReasons(); if (!empty($reasons) && !empty($list)) { $res = []; foreach ($list as $rk => $rv) { if ($rk != static::CLAIM_OTHER && ($rk & $reasons)) { $res[] = $rv; } } if (($reasons & static::CLAIM_OTHER) && !empty($comment)) { $res[] = $comment; } return join(', ', $res); } return ''; } /** * Добавление жалобы пользователем * @param int $itemID ID объявления * @param int $userID ID пользователя * @param array $reasons причины жалобы (из списка 'listings-item-claim-reasons') * @param string $message текст жалобы * @param array $opts доп. настройки [throttle] * @return bool */ public function addItemClaim($itemID, $userID, array $reasons, $message = '', array $opts = []) { $reason = array_sum($reasons); if (!$reason) { $this->errors->set(_t('item-claim', 'Specify the reason')); return false; } if ($reason & static::CLAIM_OTHER) { if (mb_strlen($message) < 10) { $this->errors->set(_t('item-claim', 'Describe the reason in more details'), 'comment'); return false; } } if (! empty($opts['throttle'])) { $throttle = is_array($opts['throttle']) ? $opts['throttle'] : []; if ($this->tooManyRequests($throttle['actionKey'] ?? 'listings-item-claim', $throttle)) { return false; } } $claimID = $this->model->claimSave(0, [ 'reason' => $reason, 'message' => $message, 'item_id' => $itemID, 'user_id' => $userID, ]); if ($claimID > 0) { $this->claimsCounterUpdate(1); $this->model->itemSave($itemID, [ 'claims_cnt = claims_cnt + 1' ]); } return true; } /** * Актуализация счетчика необработанных жалоб на объявления * @param int|null $increment * @return void */ public function claimsCounterUpdate(?int $increment) { if (is_null($increment)) { $count = $this->model->claimsListing(['viewed' => 0], true); config::save('listings_items_claims', $count, true); } else { config::saveCount('listings_items_claims', $increment, true); } } /** * Отправить письмо со ссылкой на объявление другу на email * @param int $itemID ID объявления * @param string $email адрес друга * @return bool */ public function sendItemToFriendEmail($itemID, $email) { $itemData = $this->model->itemData($itemID, ['id','title','link','user_id']); if (empty($itemData)) { return false; } return $this->app->sendMailTemplate( [ 'item_id' => $itemID, 'item_title' => $itemData['title'], 'item_link' => $itemData['link'], ], 'listings_item_sendfriend', $email ); } /** * Актуализация счетчика объявлений ожидающих модерации * @param mixed $increment * @return mixed|void */ public function moderationCounterUpdate($increment = null) { $key = 'listings_items_moderating'; if ($increment === null) { $count = $this->model->itemsModeratingCounter(); config::save($key, $count, true); } elseif ($increment === false) { return config::get($key, 0, TYPE_UINT); } elseif (is_numeric($increment)) { config::saveCount($key, $increment, true); } } /** * Период публикации объявления * @return ItemPublicationPeriod */ public function itemPublicationPeriod() { $i = new ItemPublicationPeriod(); $i->init(); return $i; } /** * Формирование цены объявления * @return ItemPrice */ public function itemPrice() { return ItemPrice::i(); } /** * Брать контакты объявления из профиля (пользователя / компании) * @return bool */ public function getItemContactsFromProfile() { return ($this->config('listings.item.contacts', 1, TYPE_UINT) === 2); } /** * Формируем ключ активации ОБ * @return array (code, link, expire) */ public function getActivationInfo() { $data = []; $data['key'] = md5(uniqid(SITEHOST . config::sys('listings.items.activation.salt', 'ASDAS(D90--00];&%#97665.,:{}', TYPE_STR) . microtime(), true)); $data['link'] = $this->url('item.activate', ['c' => $data['key']]); $expireDays = $this->config('listings.items.activation.expire', '3 day', TYPE_STR); if (is_numeric($expireDays)) { $expireDays .= ' day'; } $data['expire'] = date('Y-m-d H:i:s', strtotime('+' . $expireDays)); return $data; } /** * Кеширование данных категории в памяти * @param int $catID * @return array */ public function catData($catID) { if (empty($catID)) { return []; } if (! isset($this->cache['cat'][$catID])) { $this->cache['cat'][$catID] = $this->model->catData($catID); } return $this->cache['cat'][$catID]; } /** * Кеширование данных объявления в памяти * @param int $itemID * @return array|mixed */ public function itemData($itemID) { if (empty($itemID)) { return []; } if (! isset($this->cache['item'][$itemID])) { $this->cache['item'][$itemID] = $this->model->itemData($itemID); } return $this->cache['item'][$itemID]; } /** * Проверка данных объявления из массива * @param array $data данные * @param array $opts параметры * @return array */ public function validateItemData(array $data, $opts = []) { $opts = $this->defaults($opts, [ 'form' => null, 'adminPanel' => $this->isAdminPanel(), 'id' => 0, 'afterValidation' => null, ]); $form = $opts['form']; if (is_string($form)) { $form = new $form(); } if (is_null($form) || ! $form instanceof ItemForm) { $form = new ItemForm(); } if ($opts['adminPanel']) { $form->validationAdminPanel = true; } if ($opts['id']) { $form->load($opts['id']); } $data = $form->validate($data); if ($opts['afterValidation'] instanceof Closure) { ($opts['afterValidation'])($data, $form); } return $data; } /** * Построить значение для фильтра поиска по региону * @param int $catRegions привязка к региону в категории (значение поля geo_regions для категории) * @param int $delivery флаг доставки в регионы * @param array $db гео данные * @see Geo::regionParents * @return string */ public function itemGeoPath($catRegions, $delivery, $db = []) { if (! $catRegions) { return '-ANY-'; } if (empty($db)) { return '-ANY-'; } if ($delivery) { return '-' . ($db['geo_region1'] ?? 0) . '-ANY-'; } return '-' . join('-', $db) . '-'; } /** * Проверка превышения лимита объявлений * @param array $filter фильтр для выборки объявлений * @param array $opts доп. параметры * @return bool */ public function limitsExceed(array $filter, array $opts = []): bool { if (! bff::servicesEnabled()) { return false; } $opts = $this->defaults($opts, [ 'silent' => false, 'status' => false, 'getReason' => null, 'getAlert' => null, ]); return $this->app->filter('listings.limits.exceed', false, $filter, $opts); } /** * Создать объявление * @param array $data данные для объявления * @return int */ public function itemAdd(array $data = [], $opts = []) { do { if (empty($data['user_id'])) { $this->errors->set(_t('item-form', 'Only logged in users can publish listings'), 'user_id'); break; } $afterValidation = $opts['afterValidation'] ?? null; $after = function (&$data, $form) use ($afterValidation) { /** @var ItemForm $form */ if ($this->errors->any()) { return; } if (! isset($data['publicated'])) { $form->submitAddPublicated($data); } if (! empty($opts['limits'])) { $form->submitAddLimits($data); } if ($afterValidation instanceof Closure) { ($afterValidation)($data, $form); } }; $opts['afterValidation'] = $after; $data = $this->validateItemData($data, $opts); if ($this->errors->any()) { break; } if (! isset($data['status'])) { $data['status'] = static::STATUS_PUBLICATED; } if (! isset($data['moderated'])) { $data['moderated'] = 1; } $id = $this->model->itemSave(0, $data); return $id; } while (false); return 0; } /** * Является ли текущий авторизованный пользователь владельцем объявления * @param int $itemID ID объявления * @param int|null $itemUserID ID пользователя объявления или NULL (получаем из БД) * @return bool */ public function isItemOwner($itemID, $itemUserID = null) { if (User::guest()) { return false; } if (is_null($itemUserID)) { $itemData = $this->model->itemData($itemID, ['user_id','status']); # объявление не найдено или было помечено как "удаленное" if (empty($itemData) || $itemData['status'] == static::STATUS_DELETED) { return false; } $itemUserID = $itemData['user_id']; } return ($itemUserID > 0 && User::isCurrent($itemUserID)); } /** * Метод обрабатывающий ситуацию с активацией пользователя (завершением регистрации) * @param int $userID ID пользователя * @return void */ public function onUserRegistered($userID) { # активируем объявления пользователя $filter = [ 'user_id' => $userID, 'status' => static::STATUS_NOTACTIVATED, ]; $update = [ 'activate_key' => '', # чистим ключ активации 'publicated' => $this->db->now(), 'publicated_order' => $this->db->now(), 'status_prev' => static::STATUS_NOTACTIVATED, 'status' => static::STATUS_PUBLICATED, 'moderated' => 0, # помечаем на модерацию 'is_moderating' => 1, 'is_publicated' => ($this->premoderation() ? 0 : 1), ]; $optionsWrap = [ 'context' => 'user-activated', ]; $period = $this->itemPublicationPeriod(); if ($period->isAvailable()) { $periodDefault = 0; $period->options(['default' => & $periodDefault]); # default period: $activated = $this->model->itemsUpdateByFilter( $update + ['publicated_to' => $period->publishTo(['days' => $periodDefault])], $filter + ['publicated_period' => $periodDefault], $optionsWrap ); # custom period: $this->model->itemsDataByFilter($filter, ['id', 'publicated_period'], [ 'iterator' => function ($item) use ($period, &$activated, $update, $optionsWrap) { $update['publicated_to'] = $period->publishTo(['period' => $item['publicated_period']]); $success = $this->model->itemsUpdateByFilter($update, ['id' => [$item['id']]], $optionsWrap); if ($success) { $activated++; } }, 'context' => 'user-activated', ]); } elseif ($period->isUnavailable() || $period->isIndefinitely()) { $update['publicated_to'] = $period->publishTo(); $activated = $this->model->itemsUpdateByFilter($update, $filter, $optionsWrap); } if ($activated > 0) { # накручиваем счетчик кол-ва объявлений авторизованного пользователя Users::model()->userCounterSave($userID, 'items', $activated); # +N # обновляем счетчик "на модерации" $this->moderationCounterUpdate(); } } /** * Метод обрабатывающий ситуацию с блокировкой/разблокировкой пользователя * @param int $userID ID пользователя * @param bool $blocked true - заблокирован, false - разблокирован * @param array $opts доп. параметры * @return void */ public function onUserBlocked($userID, $blocked, array $opts = []) { $opts = $this->defaults($opts, [ 'company_id' => ['>=', 0], 'context' => ($blocked ? 'user-blocked' : 'user-unblocked'), 'blocked_reason' => _t('listings', 'User account blocked'), ]); if ($blocked) { # при блокировке: # - скрываем из списка на модерации уже заблокированные $this->model->itemsUpdateByFilter([ 'is_moderating' => 0, # помечаем как заблокированные до блокировки аккаунта 'status_prev' => static::STATUS_BLOCKED, ], [ 'user_id' => $userID, 'company_id' => $opts['company_id'], 'is_publicated' => 0, 'status' => static::STATUS_BLOCKED, ], ['context' => $opts['context']]); # - блокируем все публикуемые/снятые с публикации $this->model->itemsUpdateByFilter([ 'blocked_num = blocked_num + 1', 'status_prev = status', 'status' => static::STATUS_BLOCKED, 'is_publicated' => 0, 'is_moderating' => 0, # скрываем из списка на модерации 'blocked_reason' => $opts['blocked_reason'], ], [ 'user_id' => $userID, 'company_id' => $opts['company_id'], 'status' => [static::STATUS_PUBLICATED, static::STATUS_PUBLICATED_OUT], ], ['context' => $opts['context']]); } else { # при разблокировке: # - разблокируем (кроме заблокированных до блокировки аккаунта) $changed = $this->model->itemsUpdateByFilter([ 'status = status_prev', //'blocked_reason' => '', # оставляем последнюю причину блокировки ], [ 'user_id' => $userID, 'company_id' => $opts['company_id'], 'is_publicated' => 0, 'status' => static::STATUS_BLOCKED, 'status_prev' => [static::STATUS_PUBLICATED, static::STATUS_PUBLICATED_OUT], ], ['context' => $opts['context']]); if ($changed > 0) { # - публикуем опубликованные до блокировки аккаунта $filter = [ 'user_id' => $userID, 'company_id' => $opts['company_id'], 'is_publicated' => 0, 'status' => static::STATUS_PUBLICATED, 'deleted' => 0, ]; $update = [ 'is_publicated' => 1, ]; if ($this->premoderation()) { $filter['moderated'] = ['>', 0]; } $this->model->itemsUpdateByFilter($update, $filter, [ 'context' => $opts['context'], ]); } # - возвращаем в список на модерации $this->model->itemsUpdateByFilter([ 'is_moderating' => 1, ], [ 'user_id' => $userID, 'company_id' => $opts['company_id'], 'is_publicated' => ['>=', 0], 'status' => [static::STATUS_BLOCKED, static::STATUS_PUBLICATED, static::STATUS_PUBLICATED_OUT], 'moderated' => ['!=', 1], ], [ 'context' => $opts['context'], ]); } # обновляем счетчик "на модерации" $this->moderationCounterUpdate(); } /** * Метод обрабатывающий ситуацию с удалением пользователя * @param int $userID ID пользователя * @param array $options доп. параметры удаления * @return void */ public function onUserDeleted($userID, array $options = []) { # Объявления пользователя - помечаем как удаленные $this->model->itemsDeleteByUser($userID); # Комментарии к объявлениям (своим/других пользователей) $comments = $this->itemComments(); $owner = ItemComments::commentDeletedByCommentOwner; if (isset($options['initiator']) && $options['initiator'] == 'admin') { $owner = ItemComments::commentDeletedByModerator; } $comments->commentsDeleteByUser($userID, $owner, [ 'markDeleted' => true, ]); # Избранные объявления $this->model->itemsFavDelete($userID); # Жалобы на объявления (не удаляем) } /** * Метод обрабатывающий ситуацию с блокировкой/разблокировкой компании * @param int $companyId ID компании * @param bool $blocked true - заблокирован, false - разблокирован * @param int $userId ID пользователя (владельца компании) * @return void */ public function onCompanyBlocked($companyId, $blocked, $userId) { # Блокируем объявления компании $this->onUserBlocked($userId, $blocked, [ 'company_id' => $companyId, 'context' => ($blocked ? 'company-blocked' : 'company-unblocked'), 'blocked_reason' => _t('listings', 'Company account blocked'), ]); } /** * Метод обрабатывающий событие смены курса валюты * @param int $id ID валюты * @param float $rate новый курс валюты * @param array $options доп. параметры: context * @return void */ public function onCurrencyRateChange(int $id, float $rate, array $options = []) { if ( ! empty($options['context']) && $options['context'] === 'site-currency-rate-autoupdate' && ! $this->config('currency.rates.update.bbs', false, TYPE_BOOL) ) { return; } $default = Currency::id(); if ($id != $default) { $this->model->itemsUpdateByFilter([ 'price_search = ROUND(price * :rate, 2)' ], [ 'is_publicated' => 1, 'status' => static::STATUS_PUBLICATED, 'price_curr' => $id, ], [ 'bind' => [ ':rate' => $rate, ], 'context' => 'currency-rate-change', ]); } } /** * Заблокировать объявление * @param int $id ID объявления * @param array $opts @ref параметры * @return bool */ public function itemBlock($id, array &$opts = []): bool { $opts = $this->defaults($opts, [ 'blocked_id' => static::BLOCK_OTHER, 'blocked_reason' => '', 'isBlocked' => false, 'mailSend' => true, 'counterUpdate' => true, ]); do { if (! $id) { $this->errors->unknownRecord(); break; } $data = $this->model->itemData($id, ['status','status_prev','user_id','blocked_reason']); if (empty($data)) { $this->errors->unknownRecord(); break; } if ($data['status'] == static::STATUS_DELETED) { $this->errors->impossible(); break; } $isBlocked = ($data['status'] == static::STATUS_BLOCKED); $blockedID = $opts['blocked_id']; $update = [ 'moderated' => 1, 'blocked_id' => $blockedID, 'blocked_reason' => $opts['blocked_reason'], ]; if (!$isBlocked) { $update[] = 'blocked_num = blocked_num + 1'; $update[] = 'status_prev = status'; $update['status'] = static::STATUS_BLOCKED; } else { if ($data['blocked_reason'] != $opts['blocked_reason']) { $isBlocked = false; } } if ( $blockedID != static::BLOCK_OTHER && $blockedID != static::BLOCK_FOREVER ) { $reasons = $this->blockedReasons(); if (isset($reasons[ $blockedID ])) { $update['blocked_reason'] = $reasons[ $blockedID ]; } } $opts['blocked_reason'] = $update['blocked_reason']; $res = $this->model->itemSave($id, $update); if ($res && !$isBlocked) { $isBlocked = true; # отправляем email-уведомление о блокировке ОБ do { if ( $data['status'] == static::STATUS_NOTACTIVATED || $data['status'] == static::STATUS_DELETED ) { break; } if (! $data['user_id']) { break; } if (! $opts['mailSend']) { break; } $emailData = $this->model->itemData2Email($id); if (empty($emailData)) { break; } $this->app->sendMailTemplate( [ 'name' => $emailData['name'], 'email' => $emailData['email'], 'user_id' => $emailData['user_id'], 'item_id' => $emailData['item_id'], 'item_link' => $emailData['item_link'], 'item_title' => $emailData['item_title'], 'blocked_reason' => $update['blocked_reason'], ], 'listings_item_blocked', $emailData['email'], false, '', '', $emailData['lang'] ); } while (false); } $opts['isBlocked'] = $isBlocked; # обновляем счетчик "на модерации" if ($opts['counterUpdate']) { $this->moderationCounterUpdate(); } return true; } while (false); return false; } /** * Снятие объявление с публикации * @param int $id ID объявления * @param array $opts параметры * @return bool */ public function itemUnpublicate($id, array &$opts = []): bool { $opts = $this->defaults($opts, [ 'counterUpdate' => true, ]); do { if (! $id) { $this->errors->unknownRecord(); break; } $data = $this->model->itemData($id, [ 'id', 'status', 'moderated', 'publicated', 'publicated_to', ]); if (empty($data) || $data['status'] != static::STATUS_PUBLICATED) { $this->errors->impossible(); break; } $update = [ 'status_prev = status', 'status' => static::STATUS_PUBLICATED_OUT, 'moderated' => 1, 'publicated_to' => $this->db->now(), # оставляем все текущие услуги активированными ]; $res = $this->model->itemSave($id, $update); if (empty($res)) { $this->errors->impossible(); break; } # обновляем счетчик "на модерации" if ($opts['counterUpdate']) { $this->moderationCounterUpdate(); } return true; } while (false); return false; } /** * Продление публикации объявления * @param int $id ID объявления * @param array $opts параметры * @return bool */ public function itemRefresh($id, array &$opts = []): bool { $opts = $this->defaults($opts, [ 'topUp' => false, # поднять вверх списка 'counterUpdate' => true, ]); do { if (! $id) { $this->errors->unknownRecord(); break; } $data = $this->model->itemData($id, [ 'id', 'status', 'moderated', 'publicated', 'publicated_to', ]); if (empty($data)) { $this->errors->unknownRecord(); break; } if (! $this->errors->no('listings.admin.item.refresh', $data)) { break; } switch ($data['status']) { case static::STATUS_NOTACTIVATED: $this->errors->set(_t('listings', 'Unable to extend the publishing of an unactivated listing')); break; case static::STATUS_BLOCKED: $this->errors->set( $data['moderated'] == 0 ? _t('listings', 'Unable to renew posting because listing is pending review') : _t('listings', 'The post could not be renewed because the listing was disapproved') ); break; case static::STATUS_PUBLICATED: # продлеваем от даты завершения срока публикации $update = [ 'publicated_to' => $this->itemPublicationPeriod()->refreshTo($data['publicated_to']), ]; # поднимаем вверх списка if ($opts['topUp']) { $update['publicated_order'] = $this->db->now(); } $this->model->itemSave($id, $update); break; case static::STATUS_PUBLICATED_OUT: # продлеваем от текущего момента + публикуем $update = [ 'publicated_to' => $this->itemPublicationPeriod()->refreshTo(), 'status_prev = status', 'status' => static::STATUS_PUBLICATED, 'moderated' => 1, ]; # поднимаем вверх списка if ($opts['topUp']) { $update['publicated'] = $this->db->now(); $update['publicated_order'] = $this->db->now(); } $this->model->itemSave($id, $update); # обновляем счетчик "на модерации" if ($opts['counterUpdate']) { $this->moderationCounterUpdate(); } break; default: $this->errors->set(_t('listings', 'The current listing status is incorrect.')); break; } return true; } while (false); return false; } /** * Одобрение объявления * @param int $id ID объявления * @param array $opts параметры * @return bool */ public function itemApprove($id, array &$opts = []): bool { $opts = $this->defaults($opts, [ 'counterUpdate' => true, ]); do { if (! $id) { $this->errors->unknownRecord(); break; } $data = $this->model->itemData($id, ['status','publicated','publicated_to','user_id','cat_id','company_id']); if (empty($data) || in_array($data['status'], [static::STATUS_NOTACTIVATED, static::STATUS_DELETED])) { $this->errors->impossible(); break; } $update = [ 'moderated' => 1, ]; if ($data['status'] == static::STATUS_BLOCKED) { /** * В случае если "Одобряем" заблокированное ОБ * => значит оно после блокировки было отредактировано пользователем * => следовательно если его период публикации еще не истек => "Публикуем", * в противном случае переводим в статус "Период публикации завершился" */ $newStatus = static::STATUS_PUBLICATED_OUT; $now = time(); $from = strtotime($data['publicated']); $to = strtotime($data['publicated_to']); if (!empty($from) && !empty($to) && $now >= $from && $now < $to) { $newStatus = static::STATUS_PUBLICATED; } $update[] = 'status_prev = status'; $update['status'] = $newStatus; } $status = $update['status'] ?? $data['status']; # Проверка лимитов if ($status == static::STATUS_PUBLICATED) { if ( $this->limitsExceed([ 'user_id' => $data['user_id'], 'company_id' => $data['company_id'], 'cat_id' => $data['cat_id'], ], ['silent' => true, 'status' => $data['status']]) ) { $update['status'] = static::STATUS_PUBLICATED_OUT; } } $this->model->itemSave($id, $update); # обновляем счетчик "на модерации" if ($opts['counterUpdate']) { $this->moderationCounterUpdate(); } return true; } while (false); return false; } /** * Удаления объявления * @param int $id ID объявления * @param array $opts параметры * @return bool */ public function itemDelete($id, array &$opts = []): bool { $opts = $this->defaults($opts, [ 'mailSend' => true, ]); do { if (! $id) { $this->errors->unknownRecord(); break; } $emailData = $this->model->itemData2Email($id); if (! $this->errors->no('listings.admin.item.delete', ['id' => $id,'email' => $emailData])) { break; } $res = $this->model->itemsDelete([$id], true); # объявление было удалено if ($res && $opts['mailSend']) { if ($emailData !== false) { $this->app->sendMailTemplate( [ 'name' => $emailData['name'], 'email' => $emailData['email'], 'user_id' => $emailData['user_id'], 'item_id' => $emailData['item_id'], 'item_link' => $emailData['item_link'], 'item_title' => $emailData['item_title'], ], 'listings_item_deleted', $emailData['email'], false, '', '', $emailData['lang'] ); } } return true; } while (false); return false; } /** * Item services * @param string|null $serviceKey * @return ItemServices | \bff\modules\svc\Service | \bff\modules\svc\ServiceManager | null */ public function itemServices(?string $serviceKey = null) { $manager = Svc::getServiceManager(ItemServices::KEY); if (! $manager) { return null; } if ($serviceKey) { $service = $manager->getService($serviceKey); if (! $service || ! $service->isEnabled()) { return null; } return $service; } return $manager; } /** * Detect if item has active service * @param array $item data * @param string $key * @return bool */ public function isItemService(array $item, string $key) { $manager = $this->itemServices(); if (! $manager) { return false; } return $manager->isItemService($item, $key); } /** * Опубликовать объявление * @param int $itemID ID объявления * @param bool $silent true - не выводить сообщения о ошибках * @return bool */ public function itemPublicate($itemID, bool $silent = false) { $item = $this->model->itemData($itemID, [ 'user_id','company_id','cat_id','status', 'publicated_to','publicated_order','publicated_period', ]); do { if (empty($item)) { if (! $silent) { $this->errors->reloadPage(); } break; } if ($item['status'] != static::STATUS_PUBLICATED_OUT) { if (! $silent) { $this->errors->reloadPage(); } break; } if ( $this->limitsExceed([ 'user_id' => $item['user_id'], 'company_id' => $item['company_id'], 'cat_id' => $item['cat_id'], ], ['silent' => $silent]) ) { break; } $period = $this->itemPublicationPeriod(); $publicated_period = $period->publishPeriod($item['publicated_period'], $item['cat_id']); $update = [ 'status' => static::STATUS_PUBLICATED, 'status_prev' => $item['status'], 'publicated' => $this->db->now(), 'publicated_period' => $publicated_period, 'publicated_to' => $period->publishTo(['period' => $publicated_period]), ]; /** * Обновляем порядок публикации (поднимаем наверх) * только в случае если разница между датой publicated_order и текущей более X дней * т.е. тем самым закрываем возможность бесплатного поднятия за счет * процедуры снятия с публикации => возобновления публикации (продления) */ $topupTimeout = $this->config('listings.publicate.topup.timeout', 7, TYPE_UINT); if ($topupTimeout > 0 && (time() - strtotime($item['publicated_order'])) >= (86400 * $topupTimeout)) { $update['publicated_order'] = $this->db->now(); } $res = $this->model->itemSave($itemID, $update); return ! empty($res); } while (false); return false; } /** * Обработка смены типа формирования geo-зависимых URL * @param string $prevType предыдущий тип формирования (Geo::URL_) * @param string $nextType следующий тип формирования (Geo::URL_) * @return void */ public function onGeoUrlTypeChanged($prevType, $nextType) { $cronManager = $this->app->cronManager(); if ($cronManager->isEnabled()) { $cronManager->executeOnce('listings', 'prefix.view'); } else { $this->errors->set(_t('@', 'Cron Manager was not started')); } } /** * Получение списка возможных дней для оповещения о завершении публикации объявления * @return array */ public function getUnpublicatedDays() { return $this->app->filter('listings.items.unpublicate.days.enotify', [1,2,5]); } /** * Отправка уведомление о скором завершении публикации объявлений * @param array $days */ public function itemsUnpublicateSoonNotify(array $days) { if (empty($days)) { return; } # кол-во отправляемых объявлений за подход $limit = $this->config('listings.items.unpublicated.soon.limit', 100, TYPE_UINT); if ($limit <= 0) { $limit = 100; } if ($limit > 300) { $limit = 300; } $now = date('Y-m-d'); # очистка списка отправленных за предыдущие дни $last = config::get('bbs_item_unpublicated_soon_last_enotify', '', TYPE_STR); if ($last !== $now) { config::save('bbs_item_unpublicated_soon_last_enotify', $now); $this->model->itemsCronUnpublicateClearLast($last); } # получаем пользователей у которых есть одно+ объявление у которого завершается срок публикации, # до завершения осталось {$days} дней (варианты) $users = $this->model->itemsCronUnpublicateSoon($days, $limit, $now); if (empty($users)) { return; } $servicesMacro = ( bff::servicesEnabled('listings') ? $this->itemServices()->getNotificationsMacro() : [] ); foreach ($users as &$v) { $this->locale->setCurrentLanguage($v['lang'], true); $v['days_in'] = tpl::declension($v['days'], _t('', 'day;days;days')); $loginAuto = Users::loginAutoHash($v); if ($v['cnt'] == 1) { # у пользователя всего одно объявление # помечаем в таблице отправленных за сегодня (если еще нет) if ($this->model->itemsCronUnpublicateSended([$v['item_id']], $now)) { continue; } $v['item_link'] = Url::dynamic($v['item_link']); $v['publicate_link'] = $v['item_link'] . '?alogin=' . $loginAuto; $v['edit_link'] = $this->url('item.edit', ['id' => $v['item_id'], 'alogin' => $loginAuto]); foreach ($servicesMacro as $serviceMacro => $serviceKey) { $v[$serviceMacro] = $this->url('item.promote', [ 'id' => $v['item_id'], 'alogin' => $loginAuto, 'svc' => $serviceKey, ]); } $this->app->sendMailTemplate($v, 'listings_item_unpublicated_soon', $v['email'], false, '', '', $v['lang']); } else { $v['items'] = explode(',', $v['items']); if ($this->model->itemsCronUnpublicateSended($v['items'], $now)) { continue; } $v['count'] = $v['cnt']; $v['count_items'] = tpl::declension($v['cnt'], _t('listings', 'listing;listings;listings')); $v['publicate_link'] = $this->url('my.items', [ 'day' => date('Y-m-d', strtotime('+' . $v['days'] . 'days')), 'act' => 'email-publicate', 'alogin' => $loginAuto, ]); $this->app->sendMailTemplate($v, 'listings_item_unpublicated_soon_group', $v['email'], false, '', '', $v['lang']); } } unset($v); } /** * Получение ключа для модерации объявлений на фронтенде * @param int $itemID ID объявления * @param string|null $checkKey ключ для проверки или NULL * @return string|bool */ public function moderationUrlKey($itemID = 0, ?string $checkKey = null) { if (is_string($checkKey)) { if (empty($checkKey) || strlen($checkKey) != 5) { return false; } } $key = substr(hash('sha256', $itemID . SITEHOST), 0, 5); if (is_string($checkKey)) { return ($key === $checkKey); } return $key; } /** * Максимальный уровень вложенности категорий * - при изменении, не забыть привести в соответствие столбцы cat_id(1-n) в таблице static::TABLE_ITEMS * - минимальное значение = 1 * @return int */ public function catsDepthLimit(): int { return $this->config('listings.categories.depth.limit', 4, TYPE_UINT); } /** * Получаем уровень подкатегории, отображаемый в фильтре * @param bool|null $isMobile мобильное устройство * @return int */ public function catsFilterLevel(?bool $isMobile = null) { $level = $this->config('listings.search.filter.catslevel', 3, TYPE_UINT); if ($level < 2) { $level = 2; } if ( ($isMobile ?? Request::isPhone()) && ! $this->config('listings.search.filter.catslevel.mobile', false, TYPE_BOOL) ) { $level = 20; } return ($level - 1); } /** * Поиск "минус слов" в строке * @param string $text строка * @param string|null $spamWord @ref найденное слово * @param string|null $lang * @return bool true - нашли минус слово */ protected function spamMinusWordsFound(string $text, ?string &$spamWord = '', ?string $lang = null) { $lang = $lang ?? $this->locale->current(); $minusWords = func::unserialize($this->config('listings.items.spam.minuswords.prepared', config::get('bbs_items_spam_minuswords', ''))); if (! empty($minusWords[$lang])) { return TextParser::minuswordsSearch($text, $spamWord, $minusWords[$lang]); } return false; } /** * Поиск "минус слов" в строке * @param array $p параметры с @ref данными * @return bool */ public function spamMinusWordsSearch($p) { if (empty($p['text'])) { return false; } if (! isset($p['word'])) { $p['word'] = ''; } return $this->spamMinusWordsFound($p['text'], $p['word'], $p['lang'] ?? null); } /** * Проверка дублирования пользователем объявлений * @param int $userID ID пользователя * @param array $data @ref данные ОБ, ищем по заголовку 'title' и/или описанию 'descr' * @return bool true - нашли похожее объявление */ public function spamDuplicatesFound($userID, array $data) { if (! $userID) { return false; } if (! $this->config('listings.items.spam.duplicates', false, TYPE_BOOL)) { return false; } $catData = []; if (! empty($data['cat_id'])) { $catData = $this->model->catData($data['cat_id'], ['tpl_title_enabled']); } $query = [0 => []]; if (!empty($data['title']) && empty($catData['tpl_title_enabled'])) { $query[0][] = 'title LIKE :title'; $query[':title'] = $data['title']; } if (!empty($data['descr'])) { $query[0][] = 'descr LIKE :descr'; $query[':descr'] = $data['descr']; } if (!empty($query[0])) { $query[0] = '(' . join(' OR ', $query[0]) . ')'; $filter = [ 'user_id' => $userID, 'status' => ['!=', static::STATUS_DELETED], ':query' => $query, ]; if ($this->model->itemsCount($filter)) { return true; } } return false; } /** * Включено формирование RSS * @return bool */ public function rssEnabled() { return $this->config('listings.rss.enabled', false, TYPE_BOOL); } /** * Отображать кнопку "Продвинуть объявление" на странице ее просмотра * @param bool $itemOwner текущий пользователь является автором объявления * @return bool */ public function itemViewPromoteAvailable(bool $itemOwner) { $sys = $this->config('listings.view.promote', 0, TYPE_UINT); switch ($sys) { case 0: # всем return true; case 1: # авторизованным return User::logined(); case 2: # автору объявления return !empty($itemOwner); } return false; } /** * Формирование списка директорий/файлов требующих проверки на наличие прав записи * @return array */ public function writableCheck() { return array_merge(parent::writableCheck(), [ $this->app->path('items', 'images') => 'dir-split', # изображения объявлений $this->app->path('cats', 'images') => 'dir-only', # изображения категорий $this->app->path('svc', 'images') => 'dir-only', # изображения платных услуг $this->app->path('tmp', 'images') => 'dir-only', # tmp $this->app->path('import') => 'dir-only', # импорт $this->app->basePath('files') => 'dir-only', # выгрузка Яндекс.Маркет ]); } /** * Проверка работы модуля * В процессе выполнения проверки в разделе "Состояние системы" */ public function systemCheck() { $key = 'items_search_sphinx'; $this->systemResolve($key); if (ItemsSearchSphinx::enabled()) { if (! $this->itemsSearchSphinx()->isRunning()) { $this->app->hh()->warning($this->module_name . '_' . $key, _t('listings', 'Sphinx indexing fails, check service', false)); } } } /** * Выполнение массовой операции над объявлениями, вызывается из админ панели или крона * @param array $params * @return array|mixed */ public function cronAdminMassAction(array $params) { if (! $this->isCron() && ! $this->isAdminPanel()) { $this->errors->impossible(); return []; } if (empty($params['action']) || ! is_string($params['action'])) { $this->errors->impossible(); return []; } if (empty($params['sql'])) { $this->errors->impossible(); return []; } if ($this->isCron() && empty($params['count'])) { $this->log(__FUNCTION__ . ' params[count] is empty'); return []; } $method = 'cronAdminMassAction_' . $params['action']; if (! method_exists($this, $method)) { $this->errors->impossible(); return []; } return call_user_func([$this, $method], $params); } /** * Выполнение массовой блокировки объявлений * @param array $params * @return array */ protected function cronAdminMassAction_block(array $params): array { if (empty($params['blocked_id'])) { $this->errors->impossible(); return []; } if (! isset($params['blocked_reason'])) { $this->errors->impossible(); return []; } $last = 0; $cnt = 0; $count = isset($params['count']) ? $params['count'] : 100; do { $sql = $params['sql']; $sql[':last_id'] = ['id > :last', ':last' => $last]; $data = $this->model->itemsSearch($sql, [ 'orderBy' => 'id', 'limit' => $count > 100 ? 100 : $count, ]); if (empty($data)) { break; } foreach ($data as $v) { $last = $v; $count--; $o = [ 'blocked_id' => $params['blocked_id'], 'blocked_reason' => $params['blocked_reason'], 'counterUpdate' => false, 'mailSend' => false, ]; if ($this->itemBlock($v, $o)) { $cnt++; } unset($o); } } while (! empty($data) && $count > 0); $this->moderationCounterUpdate(); return [ 'message' => _t('listings', 'Blocked [items]', [ 'items' => tpl::declension($cnt, _t('listings', 'listing;listings;listings')), ]), ]; } /** * Выполнение массового снятия объявлений с публикации * @param array $params * @return array */ protected function cronAdminMassAction_unpublicate(array $params): array { $last = 0; $cnt = 0; $count = isset($params['count']) ? $params['count'] : 100; do { $sql = $params['sql']; $sql[':last_id'] = ['id > :last', ':last' => $last]; $data = $this->model->itemsSearch($sql, [ 'orderBy' => 'id', 'limit' => $count > 100 ? 100 : $count, ]); if (empty($data)) { break; } foreach ($data as $v) { $last = $v; $count--; $o = [ 'counterUpdate' => false, ]; if ($this->itemUnpublicate($v, $o)) { $cnt++; } unset($o); } } while (! empty($data) && $count > 0); $this->moderationCounterUpdate(); return [ 'message' => _t('listings', 'No longer published: [items]', [ 'items' => tpl::declension($cnt, _t('listings', 'listing;listings;listings')), ]), ]; } /** * Выполнение массовой публикации объявлений * @param array $params * @return array */ protected function cronAdminMassAction_refresh(array $params): array { if (! isset($params['topUp'])) { $params['topUp'] = 0; } $last = 0; $cnt = 0; $count = isset($params['count']) ? $params['count'] : 100; do { $sql = $params['sql']; $sql[':last_id'] = ['id > :last', ':last' => $last]; $data = $this->model->itemsSearch($sql, [ 'orderBy' => 'id', 'limit' => $count > 100 ? 100 : $count, ]); if (empty($data)) { break; } foreach ($data as $v) { $last = $v; $count--; $o = [ 'topUp' => $params['topUp'], 'counterUpdate' => false, ]; if ($this->itemRefresh($v, $o)) { $cnt++; } unset($o); } } while (! empty($data) && $count > 0); $this->moderationCounterUpdate(); return [ 'message' => _t('listings', 'Posted: [items]', [ 'items' => tpl::declension($cnt, _t('listings', 'listing;listings;listings')), ]), ]; } /** * Выполнение массового одобрения объявлений * @param array $params * @return array */ protected function cronAdminMassAction_approve(array $params): array { $last = 0; $cnt = 0; $count = isset($params['count']) ? $params['count'] : 100; do { $sql = $params['sql']; $sql[':last_id'] = ['id > :last', ':last' => $last]; $data = $this->model->itemsSearch($sql, [ 'orderBy' => 'id', 'limit' => $count > 100 ? 100 : $count, ]); if (empty($data)) { break; } foreach ($data as $v) { $last = $v; $count--; $o = [ 'counterUpdate' => false, ]; if ($this->itemApprove($v, $o)) { $cnt++; } unset($o); } } while (! empty($data) && $count > 0); $this->moderationCounterUpdate(); return [ 'message' => _t('listings', 'Approved [items]', [ 'items' => tpl::declension($cnt, _t('listings', 'listing;listings;listings')), ]), ]; } /** * Выполнение массового удаления объявлений * @param array $params * @return array */ protected function cronAdminMassAction_delete(array $params): array { $last = 0; $cnt = 0; $count = isset($params['count']) ? $params['count'] : 100; do { $sql = $params['sql']; $sql[':last_id'] = ['id > :last', ':last' => $last]; $data = $this->model->itemsSearch($sql, [ 'orderBy' => 'id', 'limit' => $count > 100 ? 100 : $count, ]); if (empty($data)) { break; } foreach ($data as $v) { $last = $v; $count--; $o = [ 'mailSend' => false, ]; if ($this->itemDelete($v, $o)) { $cnt++; } } } while (! empty($data) && $count > 0); return [ 'message' => _t('listings', 'Deleted: [items]', [ 'items' => tpl::declension($cnt, _t('listings', 'listing;listings;listings')), ]), ]; } }