TYPE_NOTAGS, # название 'mtitle' => TYPE_NOTAGS, # meta-title 'mkeywords' => TYPE_NOTAGS, # meta-keywords 'mdescription' => TYPE_NOTAGS, # meta-description 'seotext' => TYPE_STR, # seotext 'titleh1' => TYPE_STR, # H1 'breadcrumb' => TYPE_STR, # хлебная крошка 'type_offer_form' => TYPE_STR, # тип "предложение" в форме 'type_offer_search' => TYPE_STR, # тип "предложение" при поиске 'type_seek_form' => TYPE_STR, # тип "ищу" в форме 'type_seek_search' => TYPE_STR, # тип "ищу" при поиске 'type_filter_title' => TYPE_STR, # название поля "Тип" 'owner_private_form' => TYPE_STR, # тип "представителя" в форме 'owner_private_search' => TYPE_STR, # тип "представителя" при поиске 'owner_business_form' => TYPE_STR, # тип "представителя" в форме 'owner_business_search' => TYPE_STR, # тип "представителя" при поиске 'subs_filter_title' => TYPE_STR, # заголовок для подкатегорий в фильтре 'tpl_title_list' => TYPE_STR, # шаблон для заголовка объявления (список) 'tpl_title_view' => TYPE_STR, # шаблон для заголовка объявления (просмотр) 'tpl_descr_list' => TYPE_STR, # шаблон для описания объявления (список) # seo: category 'category_mtitle' => TYPE_NOTAGS, # meta-title 'category_mkeywords' => TYPE_NOTAGS, # meta-keywords 'category_mdescription' => TYPE_NOTAGS, # meta-description 'category_breadcrumb' => TYPE_STR, # хлебная крошка 'category_titleh1' => TYPE_STR, # H1 'category_seotext' => TYPE_STR, # seotext # seo: view 'view_mtitle' => TYPE_NOTAGS, # meta: title 'view_mkeywords' => TYPE_NOTAGS, # meta: keywords 'view_mdescription' => TYPE_NOTAGS, # meta: description 'view_msocialtext' => TYPE_ARRAY_NOTAGS, # meta: socialtext 'view_seotext' => TYPE_STR, # seotext ]; /** @var array переводимые поля типов категорий объявлений */ public $langCategoriesTypes = [ 'title' => TYPE_STR, # название ]; /** @var array переводимые поля услуг объявлений */ public $langSvcServices = [ 'title_view' => TYPE_STR, # название 'description' => TYPE_STR, # описание (краткое) 'description_full' => TYPE_STR, # описание (подробное) ]; /** @var array переводимые поля пакетов услуг объявлений */ public $langSvcPacks = [ 'title_view' => TYPE_NOTAGS, # название 'description' => TYPE_STR, # описание (краткое) 'description_full' => TYPE_STR, # описание (подробное) ]; /** @var array переводимые поля объявлений */ public $langItem = [ 'title' => [TYPE_NOTAGS], # название 'descr' => [TYPE_TEXT], # описание 'title_list' => [TYPE_NOTAGS], # название в списке (на основе шаблона категории) 'descr_list' => [TYPE_TEXT], # описание в списке (на основе шаблона категории) ]; /** @var array автоматически переводимые поля объявлений */ public $langItemTranslatable = [ 'title', 'descr', ]; /** @var array переводимые SEO поля объявлений */ public $langItemSEO = [ 'mtitle' => [TYPE_NOTAGS], # meta: title 'mkeywords' => [TYPE_NOTAGS], # meta: keywords 'mdescription' => [TYPE_NOTAGS], # meta: description 'msocialtext' => [TYPE_ARRAY_NOTAGS], # meta: socialtext 'seotext' => [TYPE_STR], # meta: seotext ]; protected $cache = []; public function onNewRequest($request) { parent::onNewRequest($request); $this->cache = []; } /** * Компонент для работы с деревом категорий объявлений * @return NestedSetsTree */ public function treeCategories() { if ($this->treeCategories === null) { $this->treeCategories = new NestedSetsTree(static::TABLE_CATEGORIES); } return $this->treeCategories; } /** * Category model * @param int|null $id * @param array $columns * @param array $with * @param string|null $lang * @return \modules\listings\models\Category | \bff\db\illuminate\Model |array */ public function category($id = null, $columns = ['*'], array $with = [], $lang = null) { $model = $this->model('Category'); if (! empty($id)) { return $model->one($id, $columns, $with, $lang); } return $model; } /** * Item model * @param int|null $id * @param array $columns * @param array $with * @param string|null $lang * @return \modules\listings\models\Item | \bff\db\illuminate\Model |array */ public function item($id = null, $columns = ['*'], array $with = [], $lang = null) { $model = $this->model('Item'); if (! empty($id)) { return $model->one($id, $columns, $with, $lang); } return $model; } # -------------------------------------------------------------------- # Объявления /** * Список объявлений (admin) * @param array $filter фильтр списка объявлений * @param bool $countOnly только подсчет кол-ва объявлений * @param array $opts доп. параметры: orderBy, limit, offset * @return mixed */ public function itemsListing(array $filter = [], bool $countOnly = false, array $opts = []) { $opts = $this->defaults($opts, [ 'context' => 'admin-search', 'lang' => $this->locale->current(), 'count' => $countOnly, 'fields' => [], 'joinTables' => [], ]); $itemsID = $this->itemsSearch($filter, $opts); if ($countOnly) { return $itemsID; } if (empty($itemsID)) { return []; } $fields = $opts['fields']; $joinTables = $opts['joinTables']; $this->db->tag('listings-items-listing-data', ['fields' => &$fields, 'join' => &$joinTables]); return $this->db->select('SELECT I.id, I.link, I.title, I.created, I.imgcnt, I.comments_cnt, I.status, I.moderated, I.import, I.user_ip, I.user_id, C.title as cat_title, I.cat_id1 ' . ( ! empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I ' . join(' ', $joinTables) . ' LEFT JOIN ' . static::TABLE_CATEGORIES_LANG . ' C ON C.id = I.cat_id1 AND C.lang = :lang WHERE ' . $this->prepareIN('I.id', $itemsID) . ' ORDER BY FIELD(I.id,' . join(',', $itemsID) . ')', [ ':lang' => $opts['lang'], ]); } /** * Список объявлений по фильтру (frontend) * @param array $filter фильтр списка объявлений * @param bool $countOnly только подсчет кол-ва объявлений * @param int|array $opts доп. параметры * @return mixed */ public function itemsList(array $filter = [], bool $countOnly = false, array $opts = []) { $opts = $this->defaults($opts, [ 'context' => 'listings-items-list', 'count' => $countOnly, 'fields' => [], # список дополнительных полей 'joinTables' => [], 'listCurrency' => 0, # ID валюты в списке 'user' => User::id(), # ID текущего пользователя (для пометки избранных) 'lang' => $this->locale->current(), 'favs' => true, # помечать избранные 'districts' => true, # получать данные о районах города 'images' => true, # получать данные о всех изображениях объявления 'ttl' => 120, # кешировать запрос (сек) ]); # Поиск объявлений $itemsID = $this->itemsSearch($filter, $opts); if ($countOnly) { return $itemsID; } if (empty($itemsID)) { return []; } $lang = $opts['lang']; $fields = $opts['fields']; $districtsEnabled = $opts['districts'] && Geo::districtsEnabled(); if ($districtsEnabled) { $fields[] = 'I.district_id'; } $joinTables = $opts['joinTables']; $this->db->tag('listings-items-list-data', ['fields' => &$fields, 'join' => &$joinTables, 'opts' => $opts]); $data = $this->db->select( 'SELECT I.id, I.cat_id, I.cat_type, I.user_id, I.company_id, I.status, I.lang, I.title, I.title_list, I.descr, I.descr_list, I.link, I.img_s, I.img_m, I.imgcnt as imgs, I.addr_lat as lat, I.addr_lon as lon, I.addr_addr, I.price, I.price_curr, I.price_ex, I.price_ex_mod, I.svc, I.publicated, I.publicated_order as publicated_last, I.modified, C.settings as cat_settings, CL.title as cat_title, R.title_' . $lang . ' AS city_title, I.regions_delivery ' . ( ! empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I ' . join(' ', $joinTables) . ' LEFT JOIN ' . Geo::TABLE_REGIONS . ' R ON I.geo_city = R.id INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = I.cat_id INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON CL.id = I.cat_id AND CL.lang = :lang WHERE I.id IN (' . join(',', $itemsID) . ') ORDER BY FIELD(I.id,' . join(',', $itemsID) . ')', [':lang' => $lang], $opts['ttl'] ); if ($districtsEnabled) { $districtsList = []; foreach ($data as $v) { if ($v['district_id']) { $districtsList[] = $v['district_id']; } } if (! empty($districtsList)) { $districtsList = array_unique($districtsList); $districtsList = $this->db->tag('listings-items-list-districts')->select_key( 'SELECT id, title_' . $lang . ' as t FROM ' . Geo::TABLE_REGIONS_DISTRICTS . ' WHERE ' . $this->prepareIN('id', $districtsList) ); } } # проверим наличие перевода $translate = $this->db->tag('listings-items-list-translate')->select_key( 'SELECT id, title, title_list, descr, descr_list FROM ' . static::TABLE_ITEMS_LANG . ' WHERE lang = :lng AND ' . $this->prepareIN('id', $itemsID), 'id', [':lng' => $lang] ); # текущий регион в фильтре $filterRegion = Geo::filter(); # отмеченные как избранные if ($opts['favs']) { $favoritesID = Listings::favorites()->getList($opts['user'], false, $itemsID); } # данные об изображениях if ($opts['images']) { $images = Listings::itemImages(); $imagesSizes = [$images::szSmall, $images::szMedium, $images::szView]; if (is_string($opts['images']) || is_array($opts['images'])) { $imagesSizes = is_string($opts['images']) ? [$opts['images']] : $opts['images']; } $imagesData = $images->getItemsImagesData($itemsID); foreach ($imagesData as $itemID => $itemImages) { $itemImagesUrls = []; $images->setRecordID($itemID); foreach ($itemImages as $image) { $itemImagesUrls[$image['id']] = $images->getURL($image, $imagesSizes); $itemImagesUrls[$image['id']]['vertical'] = $image['vertical']; } $imagesData[$itemID] = $itemImagesUrls; } } $this->catsSettingsMerge($data, ['name' => 'cat_settings', 'merge' => false]); $showPublicated = $this->config('listings.search.publication.date', 1); foreach ($data as &$v) { # выполнялось ли поднятие $v['publicated_up'] = false; if ($showPublicated && $v['publicated'] !== $v['publicated_last']) { $publicated = strtotime($v['publicated']); $publicated_last = strtotime($v['publicated_last']); if ( $publicated_last > $publicated && ($publicated_last - $publicated) >= 86400 /* 1 day */ ) { $v['publicated_up'] = true; } } # форматируем дату публикации if ($showPublicated) { $v['publicated'] = tpl::date($v['publicated'], false); # первичная публикация $v['publicated_last'] = tpl::date($v['publicated_last'], false); # последнее поднятие } else { $v['publicated'] = ''; $v['publicated_last'] = ''; } # помечаем избранные $v['fav'] = ($opts['favs'] && in_array($v['id'], $favoritesID)); # форматируем цену ItemPrice::i()->format(['data' => &$v], ['viewCurrencyID' => $opts['listCurrency'], 'lang' => $lang]); # формируем ссылку $v['link'] = Url::dynamic($v['link'], [], ['lang' => $lang]); # скроем город для категорий, в которых не разрешено гео if (empty($v['cat_settings']['geo']['enabled'])) { $v['city_title'] = ''; } # alt для изображения в списке $v['img_alt'] = $v['title'] . ' ' . $v['city_title']; # район города if ($districtsEnabled && $v['district_id'] && ! empty($districtsList[ $v['district_id'] ])) { $v['district_title'] = $districtsList[ $v['district_id'] ]['t']; } # доставка в регионы if (!empty($v['regions_delivery'])) { $v['city_title'] = _t('listings', 'Delivery from [city]', [ 'city' => $v['city_title'], 'filter_region' => ($filterRegion['id'] ? $filterRegion['title'] : ''), ], $lang); } # перевод if ($v['lang'] != $lang && ! empty($translate[ $v['id'] ])) { foreach ($translate[ $v['id'] ] as $k => $vv) { if ($k == 'id' || empty($vv)) { continue; } $v[$k] = $vv; } } # автозаголовок if (! empty($v['title_list'])) { $v['title'] = $v['title_list']; } # услуги $v['svc'] = explode(',', $v['svc']); # изображения $v['images'] = $imagesData[ $v['id'] ] ?? []; } unset($v); $this->app->hook('listings.model.items.list.data', ['data' => &$data,'filter' => &$filter,'opts' => &$opts]); return $data; } /** * Список объявлений по фильтру для экспорта * @param array $filter фильтр списка объявлений * @param bool $countOnly только подсчёт кол-ва * @param array $opts доп. параметры: limit, orderBy, lang, fields, joinTables * @return mixed */ public function itemsListExport(array $filter, bool $countOnly = false, array $opts = []) { $opts = $this->defaults($opts, [ 'context' => 'items-list-export', 'count' => $countOnly, 'lang' => $this->locale->current(), 'fields' => [], 'joinTables' => [], 'limit' => 0, ]); $itemsID = $this->itemsSearch($filter, $opts); if ($countOnly) { return $itemsID; } if (empty($itemsID)) { return []; } $lang = $opts['lang']; $this->db->tag('listings-items-list-export-data', ['fields' => &$opts['fields'], 'join' => &$opts['joinTables'], 'opts' => $opts]); $data = $this->db->select_key('SELECT I.id, I.title_edit as title, I.descr, I.user_id, I.company_id, I.cat_id, I.geo_city, R.title_' . $lang . ' as city_title, I.metro_id, RM.title_' . $lang . ' as metro_title, U.email, I.addr_addr, I.addr_lat, I.addr_lon, I.district_id, I.cat_type, I.lang, I.status, I.price, I.price_curr, I.price_ex, I.phones, I.contacts, I.name, U.phone as u_phone, U.phones as u_phones, U.contacts as u_contacts, I.video, I.regions_delivery, I.owner_type, IL.title AS title_trans, IL.descr AS descr_trans ' . ( ! empty($opts['fields']) ? ',' . join(',', $opts['fields']) : '') . ' FROM ' . static::TABLE_ITEMS . ' I ' . (!empty($opts['joinTables']) ? join(' ', $opts['joinTables']) : '') . ' INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON CL.id = I.cat_id AND CL.lang = :lang INNER JOIN ' . Users::TABLE_USERS . ' U ON I.user_id = U.user_id LEFT JOIN ' . Geo::TABLE_REGIONS . ' R ON I.geo_city = R.id LEFT JOIN ' . Geo::TABLE_REGIONS_METRO_STATIONS . ' RM ON I.metro_id = RM.id LEFT JOIN ' . static::TABLE_ITEMS_LANG . ' IL ON I.id = IL.id AND IL.lang = :lang WHERE I.id IN (' . join(',', $itemsID) . ') ORDER BY FIELD(I.id,' . join(',', $itemsID) . ')', 'id', [ ':lang' => $lang, ]); if (empty($data)) { return []; } foreach ($data as &$v) { # translate: if ($v['lang'] != $lang) { foreach ($this->langItemTranslatable as $traslatableField) { if (! empty($v[$traslatableField . '_trans'])) { $v[$traslatableField] = $v[$traslatableField . '_trans']; } } } # contacts: $v['contacts'] = Users::contactsToArray($v['contacts']); $v['u_contacts'] = Users::contactsToArray($v['u_contacts']); } unset($v); return $data; } /** * Данные об объявлениях для экспорта на печать * @param array $filter фильтр объявлений для экспорта * @param array $opts * @return array данные об объявлениях */ public function itemsListExportPrint(array $filter, array $opts = []) { $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); $lang = $opts['lang']; $filter = $this->prepareFilter($filter, 'I'); $this->db->tag('listings-items-list-export-print-data', ['filter' => &$filter]); $items = $this->db->select_key( 'SELECT I.*, RM.title_' . $lang . ' AS metro, U.email, U.phones as u_phones, U.contacts as u_contacts FROM ' . static::TABLE_ITEMS . ' I LEFT JOIN ' . Geo::TABLE_REGIONS_METRO_STATIONS . ' RM ON I.metro_id = RM.id, ' . Users::TABLE_USERS . ' U, ' . $filter['where'] . ' AND I.user_id = U.user_id ORDER BY I.id', 'id', $filter['bind'] ); $catsID = []; # ID категорий $regionsID = []; # ID регионов foreach ($items as &$v) { foreach (['cat_id','cat_id1','cat_id2','cat_id3','cat_id4'] as $c) { if (!$v[$c]) { continue; } if (!in_array($v[$c], $catsID)) { $catsID[] = $v[$c]; } } $regionsID[] = $v['geo_city']; $v['contacts'] = Users::contactsToArray($v['contacts']); $v['u_contacts'] = Users::contactsToArray($v['u_contacts']); } unset($v); # Названия категорий (полный путь) if (!empty($catsID)) { $catsFilter = $this->prepareFilter(['lang' => $lang, 'id' => $catsID], 'L'); $catsData = $this->db->tag('listings-items-list-export-print-categories', ['filter' => &$catsFilter]) ->select_key('SELECT L.id, L.title FROM ' . static::TABLE_CATEGORIES_LANG . ' L ' . $catsFilter['where'], 'id', $catsFilter['bind']); foreach ($items as &$v) { $v['category'] = $catsData[ $v['cat_id'] ]['title']; $catPath = ''; foreach (['cat_id1', 'cat_id2', 'cat_id3', 'cat_id4'] as $c) { if (!$v[$c]) { continue; } if ($catPath) { $catPath .= ' / '; } $catPath .= $catsData[ $v[$c] ]['title']; } $v['category_path'] = $catPath; } unset($v); } # Названия регионов if (! empty($regionsID)) { $regionsData = Geo::model()->regionsListing(['id' => array_unique($regionsID)], [ 'fields' => ['parents'], 'keyBy' => 'id', 'lang' => $lang, ]); foreach ($items as &$v) { $r = $regionsData[ $v['geo_city'] ] ?? []; $v['city'] = $r['title'] ?? ''; $v['country'] = $r['parents'][1]['title'] ?? ''; $v['region'] = $r['parents'][2]['title'] ?? ''; foreach ($r['parents'] ?? [] as $k => $vv) { $v['region' . $k] = $vv['title'] ?? ''; } } unset($v); } return $items; } /** * Список "моих" объявлений по фильтру (frontend) * @param array $filter фильтр списка объявлений * @param bool $countOnly только подсчет кол-ва объявлений * @param array $opts доп. параметры: orderBy, limit, offset, listCurrency, ... * @return mixed */ public function itemsListMy(array $filter = [], bool $countOnly = false, array $opts = []) { $opts = $this->defaults($opts, [ 'context' => 'my-items', 'count' => $countOnly, 'fields' => [], 'listCurrency' => 0, 'index' => 'users', 'lang' => $this->locale->current(), ]); $lang = $opts['lang']; if (isset($filter['onlyIDs'])) { unset($filter['onlyIDs']); $this->db->tag('listings-items-list-my-onlyid', ['filter' => &$filter]); return $this->itemsSearch($filter, $opts); } $itemsID = $this->itemsSearch($filter, $opts); if ($countOnly) { return $itemsID; } if (empty($itemsID)) { return []; } $fields = $opts['fields']; $data = $this->db->tag('listings-items-list-my-data', ['fields' => &$fields])->select( 'SELECT I.id, I.title, I.title_list, I.link, I.img_s, I.imgcnt as imgs, I.status, I.moderated, I.created, I.publicated, I.publicated_to, I.views_item_total, I.views_contacts_total, I.publicated_period, I.messages_total, I.messages_new, I.publicated_order, I.price, I.price_curr, I.price_ex, I.price_ex_mod, I.svc, I.addr_addr, I.geo_city, C.settings as cat_settings, CL.title as cat_title, R.title_' . $lang . ' AS city_title ' . ( ! empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = I.cat_id INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON CL.id = I.cat_id AND CL.lang = :lang LEFT JOIN ' . Geo::TABLE_REGIONS . ' R ON I.geo_city = R.id WHERE ' . $this->prepareIN('I.id', $itemsID) . ' ORDER BY FIELD(I.id,' . join(',', $itemsID) . ')', [':lang' => $lang] ); if (empty($data)) { return []; } $this->catsSettingsMerge($data, ['name' => 'cat_settings', 'merge' => false]); foreach ($data as &$v) { # форматируем цену ItemPrice::i()->format(['data' => &$v], ['viewCurrencyID' => $opts['listCurrency'], 'lang' => $lang]); # автозаголовок if (! empty($v['title_list'])) { $v['title'] = $v['title_list']; } # формируем ссылку $v['link'] = Url::dynamic($v['link']); $v['svc'] = explode(',', $v['svc']); # формируем адрес $v['addr'] = ''; if (! empty($v['cat_settings']['geo']['enabled']) && ! empty($v['cat_settings']['geo']['addr'])) { $addr = []; foreach (['addr_addr', 'city_title'] as $f) { if (! empty($v[$f])) { $addr[] = $v[$f]; } } $v['addr'] = join(', ', $addr); } } unset($v); $this->app->hook('listings.model.items.list.my.data', ['data' => &$data, 'filter' => &$filter, 'opts' => &$opts]); return $data; } /** * Список объявлений для переписки во внутренней почте (frontend) * @param array $itemsID ID объявлений * @param int $listCurrencyID ID текущей валюты, формируемого списка ОБ или 0 * @param string|null $lang * @return array данные об объявлениях */ public function itemsListChat(array $itemsID = [], int $listCurrencyID = 0, ?string $lang = null) { $lang = $lang ?? $this->locale->current(); $filter = $this->prepareFilter(['id' => $itemsID], 'I', [':lang' => $lang]); $fields = []; $data = $this->db->tag('listings-items-list-chat-data', ['fields' => &$fields, 'filter' => &$filter]) ->select_key( 'SELECT I.id, I.title, I.link, I.img_s, I.imgcnt as imgs, I.status, I.price, I.price_curr, I.price_ex, I.price_ex_mod, C.settings as cat_settings, CL.title as cat_title ' . ( ! empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = I.cat_id INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON CL.id = I.cat_id AND CL.lang = :lang ' . $filter['where'] . ' ' . (!empty($sqlOrder) ? ' ORDER BY I.' . $sqlOrder : ''), 'id', $filter['bind'] ); if (empty($data)) { return []; } $this->catsSettingsMerge($data, ['name' => 'cat_settings', 'merge' => false]); foreach ($data as &$v) { # форматируем цену ItemPrice::i()->format(['data' => &$v], ['viewCurrencyID' => $listCurrencyID, 'lang' => $lang]); # формируем ссылку $v['link'] = Url::dynamic($v['link']); } unset($v); return $data; } /** * Помечаем все новые сообщения переписки связанные с объявлениями как "прочитанные" * @param array $itemsID (ID объявления => кол-во прочитанных, ...) * @return void */ public function itemsListChatSetReaded(array $itemsID = []) { if (empty($itemsID)) { return; } $update = []; foreach ($itemsID as $k => $i) { $update[] = "WHEN $k THEN (messages_new - $i)"; } if (! empty($update)) { $this->itemsUpdateByFilter([ 'messages_new = CASE id ' . join(' ', $update) . ' ELSE messages_new END', ], [ 'id' => array_keys($itemsID), 'messages_new' => ['>', 0], ], ['context' => __FUNCTION__]); } } /** * Список категорий, в которые входят объявления * @param array $filter фильтр списка объявлений * @param int $numLevel уровень категорий * @param string|null $lang * @return array */ public function itemsListCategories(array $filter, int $numLevel = 1, ?string $lang = null) { $lang = $lang ?? $this->locale->current(); $filter = $this->prepareFilter($filter, 'I', [':lang' => $lang]); if (empty($numLevel) || $numLevel < 1 || $numLevel > Listings::catsDepthLimit()) { $numLevel = 1; } $catField = 'cat_id' . $numLevel; $data = $this->db->tag('listings-items-list-categories-data', ['filter' => &$filter])->select_key( 'SELECT C.id, C.pid, CL.title, COUNT(I.id) as items FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = I.' . $catField . ' INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON CL.id = I.' . $catField . ' AND CL.lang = :lang ' . $filter['where'] . ' GROUP BY I.' . $catField . ' ORDER BY C.numleft', 'id', $filter['bind'] ); return (is_array($data) ? $data : []); } /** * Список основных категорий, в которые входят объявления * @param array $filter фильтр списка объявлений * @return array */ public function itemsListCategoriesMain(array $filter, array $opts = []) { $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); # список основных категорий + кол-во объявлений в них $fields = ['CAST(SUBSTRING(SUBSTRING_INDEX(I.cat_path, \'-\', 2), 2) AS UNSIGNED) as cat_id, COUNT(*) as items']; $list1 = $this->itemsDataByFilter($filter, $fields, [ 'prefix' => 'I', 'groupKey' => 'cat_id', 'groupBy' => '1', ]); if (empty($list1)) { return []; } $list2 = $this->db->select_key('SELECT C.id, C.pid, CL.title FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE ' . $this->prepareIN('C.id', array_keys($list1)) . ' AND CL.id = C.id AND CL.lang = :lang ORDER BY C.numleft', 'id', [':lang' => $opts['lang']]); if (empty($list2)) { return []; } foreach ($list1 as $k => $v) { if (isset($list2[$k])) { $list2[$k]['items'] = $v['items']; } } return $list2; } /** * Быстрый поиск объявлений (frontend) * @param array $filter фильтр списка * @param array $opts доп. параметры: * @return mixed */ public function itemsQuickSearch(array $filter, array $opts = []) { $opts = $this->defaults($opts, [ 'limit' => 10, 'orderBy' => '', 'context' => 'search-quick', 'lang' => $this->locale->current(), ]); $itemsID = $this->itemsSearch($filter, $opts); if (empty($itemsID)) { return []; } $fields = []; $lang = $opts['lang']; $data = $this->db->tag('listings-items-quick-search-data', ['fields' => &$fields]) ->select_key('SELECT I.id, I.title, I.link, I.imgcnt, I.price, I.price_curr, I.price_ex, I.price_ex_mod, I.cat_id, I.lang, C.settings as cat_settings, C.keyword as cat_keyword, C.landing_url as cat_lpu, CL.title as cat_title, ' . 'R.title_' . $lang . ' as city_title ' . (!empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = I.cat_id INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON CL.id = I.cat_id AND CL.lang = :lang LEFT JOIN ' . Geo::TABLE_REGIONS . ' R ON I.geo_city = R.id WHERE ' . $this->prepareIN('I.id', $itemsID) . ' ORDER BY FIELD(I.id, ' . join(',', $itemsID) . ') ', 'id', [':lang' => $lang]); if (! empty($data)) { $this->catsSettingsMerge($data, ['name' => 'cat_settings', 'merge' => false]); # проверим наличие перевода $translate = $this->db->tag('listings-items-quick-search-translate')->select_key(' SELECT id, title, title_list, descr, descr_list FROM ' . static::TABLE_ITEMS_LANG . ' WHERE lang = :lng AND ' . $this->db->prepareIN('id', $itemsID), 'id', array(':lng' => $lang)); # формируем ссылки на изображения ОБ $images = Listings::itemImages(); $imagesData = $images->getItemsImagesData(array_keys($data)); foreach ($data as $id => &$v) { ItemPrice::i()->format(['data' => &$v], ['lang' => $lang]); $v['img'] = []; if ($v['imgcnt'] > 0 && !empty($imagesData[$id])) { $itemImages = $imagesData[$id]; $images->setRecordID($id); foreach ($itemImages as $img) { $v['img'][] = $images->getURL($img, $images::szMedium); } } # формируем ссылку $v['link'] = Url::dynamic($v['link']); $v['cat_link'] = Listings::url('items.search', array('keyword' => $v['cat_keyword'], 'landing_url' => $v['cat_lpu'])); # перевод if ($v['lang'] != $lang && ! empty($translate[ $v['id'] ])) { foreach ($translate[ $v['id'] ] as $k => $vv) { if ($k == 'id') { continue; } if (empty($vv)) { continue; } $v[$k] = $vv; } } # автозаголовок if (! empty($v['title_list'])) { $v['title'] = $v['title_list']; } } unset($v); } return $data; } /** * Сохранение/обновление данных об объявлении * @param int $itemID ID объявления * @param array $data данные * @return bool|int */ public function itemSave($itemID, array $data) { if (empty($data)) { return false; } unset($data['__validated']); $onCreate = $data['onCreate'] ?? null; unset($data['onCreate']); # проверка необходимости пересчета счетчиков объявлений $checkCounters = $this->itemSaveCountersCheck($itemID, $data); if (array_key_exists('phones', $data)) { if (empty($data['phones']) || !is_array($data['phones'])) { $data['phones'] = []; } $data['phones'] = serialize($data['phones']); } if (array_key_exists('contacts', $data) && is_array($data['contacts'])) { $data['contacts'] = json_encode($data['contacts']); } if (isset($data['status']) || isset($data['status_prev'])) { $data['status_changed'] = $this->now(); } $translate = false; $translates = []; if (Listings::translate()) { if (isset($data['lang'])) { $translate = true; } foreach ($this->langItemTranslatable as $translatableField) { if (isset($data[$translatableField])) { $translate = true; break; } } } if (isset($data['translates'])) { $translates = $data['translates']; unset($data['translates']); } $seoData = []; foreach (array_keys($this->langItemSEO) as $k) { if (isset($data[$k])) { $seoData[$k] = $data[$k]; unset($data[$k]); } } $data['modified'] = $this->now(); if ($itemID) { if ($translate || ! empty($translates)) { $before = $this->itemData($itemID, array_merge($this->langItemTranslatable, ['lang'])); } # обновляем данные об объявлении $result = $this->db->update(static::TABLE_ITEMS, $data, ['id' => $itemID]); if ($result) { $this->app->hook('listings.item.save', $itemID, ['data' => &$data]); if ($translate || ! empty($translates)) { $this->itemSaveTranslate($itemID, $data, $translates, $before); } if (isset($data['moderated']) && $data['moderated'] == 1) { $this->itemSaveModerated([$itemID]); } Listings::itemsSearchSphinx()->itemsUpdateByFilter($data, ['id' => $itemID]); } } else { # создаем объявление if (! isset($data['user_ip'])) { $data['user_ip'] = Request::remoteAddress(); } $data['created'] = $this->now(); if (! isset($data['lang'])) { $data['lang'] = $this->locale->current(); } $itemID = $this->db->insert(static::TABLE_ITEMS, $data); if ($itemID) { if (! empty($data['user_id']) && $data['status'] != static::STATUS_NOTACTIVATED) { # накручиваем счетчик кол-ва объявлений пользователя (+1) Users::model()->userCounterSave($data['user_id'], 'items', 1); } if ($translate || ! empty($translates)) { $this->itemSaveTranslate($itemID, $data, $translates); } if (isset($data['moderated']) && $data['moderated'] == 1) { $this->itemSaveModerated([$itemID]); } if ($onCreate) { foreach ($onCreate as $v) { if (! $v instanceof Closure) { continue; } $v($itemID, $data); } } $this->app->hook('listings.item.create', $itemID, ['data' => &$data]); } $result = $itemID; } if (! empty($seoData) && $itemID) { $this->db->langUpdate($itemID, $seoData, $this->langItemSEO, static::TABLE_ITEMS_LANG); } if ($result) { if ($checkCounters !== false) { $this->itemSaveCountersUpdate($itemID, $checkCounters); } # перестраиваем поля для индексов $this->itemsIndexesUpdate([$itemID]); } return $result; } /** * Сохранение промодерированных данных (для определения изменений при следующей модерации) * @param array $itemsID ID объявлений * @return void */ protected function itemSaveModerated(array $itemsID) { if (empty($itemsID)) { return; } $fields = array_merge($this->langItemTranslatable, ['price','addr_addr','contacts','name','phones','video']); $dynprops = Listings::dp(); $prefix = $dynprops->getSettings('datafield_prefix'); $last = $dynprops->getSettings('datafield_text_last'); for ($i = 1; $i <= $last; $i++) { $fields[] = $prefix . $i; } $this->db->tag('listings-item-save-moderated-data', ['fields' => &$fields])->select_iterator( 'SELECT id, ' . join(',', $fields) . ' FROM ' . static::TABLE_ITEMS . ' WHERE ' . $this->prepareIN('id', $itemsID), [], function ($row) use ($fields) { $id = $row['id']; unset($row['id']); $row['phones'] = func::unserialize($row['phones']); $row['contacts'] = Users::contactsToArray($row['contacts']); $this->db->update(static::TABLE_ITEMS, ['moderated_data' => serialize($row)], ['id' => $id]); } ); } /** * Список полей, при изменении которых следует выполнять пересчет * @return string[] */ protected function itemSaveCountersFields() { $fields = ['cat_id', 'status', 'moderated', 'regions_delivery', 'geo_city']; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $fields[] = 'cat_id' . $i; } $deep = Geo::maxDeep(); for ($i = 1; $i <= $deep; $i++) { $fields[] = 'geo_region' . $i; } return $fields; } /** * Данные для пересчета счетчиков для одного или нескольких объявлений * @param int|array $itemID ID объявления или нескольких объявлений * @return array */ protected function itemSaveCountersData($itemID) { $fields = $this->itemSaveCountersFields(); $result = $this->db->select_key(' SELECT I.id, ' . join(', ', $fields) . ', C.settings FROM ' . static::TABLE_ITEMS . ' I, ' . static::TABLE_CATEGORIES . ' C WHERE I.cat_id = C.id AND ' . $this->prepareIN('I.id', is_array($itemID) ? $itemID : [$itemID]), 'id'); $this->catsSettingsMerge($result, ['merge' => false]); if (is_array($itemID)) { return $result; } else { if ($itemID) { return reset($result); } else { return []; } } } /** * Проверка необходимости изменения счетчиков количества объявлений * @param int|array $itemID ID объявления или нескольких объявлений * @param array $data изменяемые данные * @return bool|array false - не надо пересчитывать иначе - данные объявления до сохранения */ protected function itemSaveCountersCheck($itemID, array $data) { $fields = $this->itemSaveCountersFields(); $check = false; foreach ($fields as $v) { if (isset($data[$v])) { $check = true; break; } } if (! $check) { return false; } return $this->itemSaveCountersData($itemID); } /** * Изменение счетчиков количества объявлений * @param int $itemID ID объявления * @param array $before данные объявления до изменения * @return void */ protected function itemSaveCountersUpdate($itemID, array $before) { $new = $this->itemSaveCountersData($itemID); if (empty($new)) { return; } $isOldPublicated = ( ! empty($before) && isset($before['status']) && $before['status'] == static::STATUS_PUBLICATED && (Listings::premoderation() ? $before['moderated'] > 0 : true) ); $isNewPublicated = ($new['status'] == static::STATUS_PUBLICATED && (Listings::premoderation() ? $new['moderated'] > 0 : true)); if (! $isOldPublicated && ! $isNewPublicated) { return; } if (! $isOldPublicated && $isNewPublicated) { # Публикация объявления $this->itemsCountersUpdate($new, 1); } elseif ($isOldPublicated && ! $isNewPublicated) { # Снятие с публикации $this->itemsCountersUpdate($before, -1); } else { # Изменение категории, региона, доставки в регионы $fields = $this->itemSaveCountersFields(); unset($fields['status'], $fields['moderated']); foreach ($fields as $v) { if ($before[$v] != $new[$v]) { $this->itemsCountersUpdate($before, -1); $this->itemsCountersUpdate($new, 1); return; } } } } /** * Изменение счетчиков количества объявлений на основе данных для нескольких объявлений * @param array $itemsID ID объявлений * @param array $before данные объявлений до изменения * @return void */ protected function itemsSaveCountersUpdate(array $itemsID, array $before) { if (sizeof($itemsID) === 1) { $this->itemSaveCountersUpdate(reset($itemsID), $before); return; } $new = $this->itemSaveCountersData($itemsID); $fields = $this->itemSaveCountersFields(); unset($fields['status'], $fields['moderated']); $counters = []; $premoderation = Listings::premoderation(); foreach ($new as $k => $n) { if (! isset($before[$k])) { continue; } $isOldPublicated = $before[$k]['status'] == static::STATUS_PUBLICATED && ($premoderation ? $before[$k]['moderated'] > 0 : true); $isNewPublicated = $n['status'] == static::STATUS_PUBLICATED && ($premoderation ? $n['moderated'] > 0 : true); if (! $isOldPublicated && !$isNewPublicated) { continue; } if (! $isOldPublicated && $isNewPublicated) { $this->itemsCountersCalculateGroup($n, 1, $counters); } elseif ($isOldPublicated && ! $isNewPublicated) { $this->itemsCountersCalculateGroup($before[$k], -1, $counters); } else { foreach ($fields as $v) { if ($before[$k][$v] != $n[$v]) { $this->itemsCountersCalculateGroup($before[$k], -1, $counters); $this->itemsCountersCalculateGroup($n, 1, $counters); continue; } } } } if (! empty($counters)) { $update = []; $where = []; $delete = []; /* [cat_id] [region_id] [delivery] [no_region] */ foreach ($counters as $c => $v) { foreach ($v as $r => $vv) { foreach ($vv as $d => $vvv) { foreach ($vvv as $nr => $cnt) { if ($cnt == 0) { continue; } $update[] = ' WHEN cat_id = ' . $c . ' AND region_id = ' . $r . ' AND delivery = ' . $d . ' AND no_region = ' . $nr . ' THEN items ' . ($cnt > 0 ? ' + ' . $cnt : ' - ' . abs($cnt)); $where[] = '(cat_id = ' . $c . ' AND region_id = ' . $r . ' AND delivery = ' . $d . ' AND no_region = ' . $nr . ')'; if ($cnt < 0) { $delete[] = '(cat_id = ' . $c . ' AND region_id = ' . $r . ' AND delivery = ' . $d . ' AND no_region = ' . $nr . ')'; } } } } } if (! empty($update)) { $data = $this->db->select('SELECT cat_id, region_id, delivery, no_region FROM ' . static::TABLE_ITEMS_COUNTERS . ' WHERE ( ' . join(' OR ', $where) . ' ) '); $exist = []; foreach ($data as $v) { $exist[ $v['cat_id'] ][ $v['region_id'] ][ $v['delivery'] ][ $v['no_region'] ] = 1; } $this->db->exec(' UPDATE ' . static::TABLE_ITEMS_COUNTERS . ' SET items = CASE ' . join(' ', $update) . ' ELSE items END WHERE ' . join(' OR ', $where)); $insert = []; foreach ($counters as $c => $v) { foreach ($v as $r => $vv) { foreach ($vv as $d => $vvv) { foreach ($vvv as $nr => $cnt) { if ($cnt <= 0) { continue; } if (! isset($exist[ $c ][ $r ][ $d ][ $nr ])) { $insert[] = '(' . $c . ', ' . $r . ', ' . $d . ', ' . $nr . ', ' . $cnt . ')'; } } } } } if (! empty($insert)) { $this->db->exec(' INSERT INTO ' . static::TABLE_ITEMS_COUNTERS . ' (cat_id, region_id, delivery, no_region, items) VALUES ' . join(',', $insert) . ' ON DUPLICATE KEY UPDATE items = items + VALUES(items);'); } } if (! empty($delete)) { $data = $this->db->select(' SELECT cat_id, region_id, delivery, no_region FROM ' . static::TABLE_ITEMS_COUNTERS . ' FORCE INDEX(id) WHERE ( ' . join(' OR ', $delete) . ' ) AND items <= 0 '); if (! empty($data)) { $delete = []; foreach ($data as $v) { $delete[] = '(cat_id = ' . $v['cat_id'] . ' AND region_id = ' . $v['region_id'] . ' AND delivery = ' . $v['delivery'] . ' AND no_region = ' . $v['no_region'] . ')'; } $this->db->exec('DELETE FROM ' . static::TABLE_ITEMS_COUNTERS . ' WHERE ' . join(' OR ', $delete)); } } } } /** * Перевод и сохранение мультиязычных данных * @param int $itemID ID объявления * @param array $data данные для перевода * @param array $translates данные для остальных языков если указанны, то не переводим * @param array $before старые данные * @return void */ protected function itemSaveTranslate($itemID, array $data, array $translates = [], array $before = []) { if (empty($itemID)) { return; } $isTranslate = Listings::translate(); # переводить используя сторонний сервис перевода if (! $isTranslate && empty($translates)) { return; } # поля генерируемые по шаблонам автозаполнения - не переводим. $noTranslate = ['title_list', 'descr_list']; $lang = $data['lang'] ?? $before['lang'] ?? false; if ($lang === false) { return; } $exist = $this->db->select_rows_key(static::TABLE_ITEMS_LANG, 'lang', [ 'id', 'lang', ], ['id' => $itemID]); $translatesFields = []; $search = []; $searchFields = $this->langItemTranslatable; $fields = array_keys($this->langItem); foreach ($fields as $f) { if (in_array($f, $searchFields)) { $search[$f . '_translates'] = $data[$f] ?? ''; } } # сохраняем указанные локали if (!empty($translates)) { $f = reset($fields); if (empty($translates[$f])) { $translates = []; # no 'title' in $translates, no need to update translation } } if (!empty($translates)) { $languages = $this->locale->getLanguages(); $k = array_search($lang, $languages); unset($languages[$k]); # save main item language in main table (TABLE_ITEMS) foreach ($languages as $l) { $d = []; foreach ($translates as $f => $v) { if (!isset($v[$l])) { continue; } $d[$f] = $v[$l]; if (in_array($f, $searchFields)) { $search[$f . '_translates'] .= ' ' . $v[$l] . ' '; } if (! in_array($f, $translatesFields)) { $translatesFields[] = $f; } } if (!empty($d)) { if (isset($exist[$l])) { $this->db->update(static::TABLE_ITEMS_LANG, $d, ['id' => $itemID, 'lang' => $l]); } else { $d['id'] = $itemID; $d['lang'] = $l; $this->db->insert(static::TABLE_ITEMS_LANG, $d); } } } if ($isTranslate) { $exist = $this->db->select_rows_key(static::TABLE_ITEMS_LANG, 'lang', [ 'id', 'lang', ], ['id' => $itemID]); } } # определяем какие поля необходимо переводить сравнив со старыми значениями if ($isTranslate) { $translate = []; foreach ($fields as $f) { if (in_array($f, $noTranslate)) { continue; } if (in_array($f, $translatesFields)) { continue; } if (!isset($data[$f])) { continue; } if (empty($exist) || empty($before[$f]) || $data[$f] != $before[$f]) { $translate[$f] = $data[$f]; } } if (!empty($translate)) { $languages = $this->locale->getLanguages(); $k = array_search($lang, $languages); unset($languages[$k]); if (!empty($languages)) { # есть что переводить => переводим $translated = ItemTranslate::i()->translate($translate, $lang, $languages); if (!empty($translated)) { foreach ($translated as $lng => $v) { if (isset($exist[$lng])) { $this->db->update(static::TABLE_ITEMS_LANG, $v, ['id' => $itemID, 'lang' => $lng]); } else { $v['id'] = $itemID; $v['lang'] = $lng; $this->db->insert(static::TABLE_ITEMS_LANG, $v); } foreach ($fields as $f) { if (!isset($v[$f])) { continue; } if (in_array($f, $searchFields)) { $search[$f . '_translates'] .= ' ' . $v[$f] . ' '; } } } } } } } # сохраняем для sphinx-поиска if (! empty($search)) { $this->db->update(static::TABLE_ITEMS, $search, ['id' => $itemID]); } } /** * Расчет изменения счетчиков при групповом обновлении * @param array $item данные объявления * @param int $cnt на сколько изменить количество * @param array $result @ref результат * @return void */ protected function itemsCountersCalculateGroup(array $item, int $cnt, array &$result) { if (empty($cnt)) { return; } $cats = []; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $cats[] = 'cat_id' . $i; } $deep = Geo::maxDeep(); $regs = []; for ($i = 1; $i <= $deep; $i++) { $regs[] = 'geo_region' . $i; } /* [cat_id] [region_id] [delivery] [no_region] */ if (! isset($result[0][0][0][0])) { $result[0][0][0][0] = $cnt; } else { $result[0][0][0][0] += $cnt; } foreach ($cats as $c) { if (empty($item[$c])) { continue; } if (! isset($result[ $item[$c] ][0][0][0])) { $result[ $item[$c] ][0][0][0] = $cnt; } else { $result[ $item[$c] ][0][0][0] += $cnt; } } if (empty($item['settings']['geo']['enabled'])) { if (! isset($result[0][0][0][1])) { $result[0][0][0][1] = $cnt; } else { $result[0][0][0][1] += $cnt; } foreach ($cats as $c) { if (empty($item[$c])) { continue; } if (! isset($result[ $item[$c] ][0][0][1])) { $result[ $item[$c] ][0][0][1] = $cnt; } else { $result[ $item[$c] ][0][0][1] += $cnt; } } } elseif ($item['regions_delivery']) { if ($item['geo_region1']) { if (! isset($result[0][ $item['geo_region1'] ][0][0])) { $result[0][ $item['geo_region1'] ][0][0] = $cnt; } else { $result[0][ $item['geo_region1'] ][0][0] += $cnt; } if (! isset($result[0][ $item['geo_region1'] ][1][0])) { $result[0][ $item['geo_region1'] ][1][0] = $cnt; } else { $result[0][ $item['geo_region1'] ][1][0] += $cnt; } foreach ($cats as $c) { if (empty($item[$c])) { continue; } if (! isset($result[ $item[$c] ][ $item['geo_region1'] ][1][0])) { $result[ $item[$c] ][ $item['geo_region1'] ][1][0] = $cnt; } else { $result[ $item[$c] ][ $item['geo_region1'] ][1][0] += $cnt; } } } } else { foreach ($regs as $r) { if (empty($item[$r])) { continue; } if (! isset($result[0][ $item[$r] ][0][0])) { $result[0][ $item[$r] ][0][0] = $cnt; } else { $result[0][ $item[$r] ][0][0] += $cnt; } foreach ($cats as $c) { if (empty($item[$c])) { continue; } if (! isset($result[ $item[$c] ][ $item[$r] ][0][0])) { $result[ $item[$c] ][ $item[$r] ][0][0] = $cnt; } else { $result[ $item[$c] ][ $item[$r] ][0][0] += $cnt; } } } } } /** * Данные об объявлении * @param int $itemID ID объявления * @param array $fields * @param bool $edit * @return array */ public function itemData($itemID, array $fields = [], bool $edit = false) { return $this->itemDataByFilter(['id' => $itemID], $fields, $edit); } /** * Данные об объявлении на основе фильтров * @param array $filter * @param array $fields * @param bool $edit * @return array */ public function itemDataByFilter(array $filter, array $fields = [], bool $edit = false) { $filter = $this->prepareFilter($filter, 'I'); if (empty($fields)) { $fields = ['*']; } if (! is_array($fields)) { $fields = [$fields]; } $params = []; foreach ($fields as $v) { $params[] = 'I.' . $v; } $params[] = 'U.email, U.phone_number, U.phone_number_verified, U.blocked as user_blocked, U.company_id as user_company_id'; if ($edit) { # берем title для редактирования $params[] = 'I.title_edit as title'; } $data = $this->db->one_array( 'SELECT ' . join(',', $params) . ' FROM ' . static::TABLE_ITEMS . ' I LEFT JOIN ' . Users::TABLE_USERS . ' U ON U.user_id = I.user_id ' . $filter['where'] . ' LIMIT 1', $filter['bind'] ); if (empty($data)) { return []; } if (isset($data['phones'])) { $data['phones'] = (!empty($data['phones']) ? func::unserialize($data['phones']) : []); } if (isset($data['link'])) { $data['link'] = Url::dynamic($data['link']); } if (isset($data['contacts'])) { $data['contacts'] = Users::contactsToArray($data['contacts']); } if (isset($data['moderated_data'])) { $data['moderated_data'] = func::unserialize($data['moderated_data']); } return $data; } /** * Получение данных объявления для отправки email-уведомления * @param int $itemID ID объявления * @return array|bool */ public function itemData2Email($itemID) { $fields = []; $data = $this->db->tag('listings-item-data-to-email', ['fields' => &$fields])->one_array( 'SELECT I.id as item_id, I.status, I.link as item_link, I.title as item_title, I.user_id, U.name, U.email, U.blocked as user_blocked, U.lang, U.user_id_ex, U.fake, S.last_login ' . (!empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I, ' . Users::TABLE_USERS . ' U, ' . Users::TABLE_USERS_STAT . ' S WHERE I.id = :id AND I.user_id = U.user_id AND I.user_id = S.user_id', [':id' => $itemID] ); do { if (empty($data)) { break; } # ОБ удалялось пользователем if ((int)$data['status'] === static::STATUS_DELETED) { break; } # ОБ неактивировано if ((int)$data['status'] === static::STATUS_NOTACTIVATED) { break; } # Проверяем владельца: # - незарегистрированный if (empty($data['user_id'])) { break; } # - фейковый if (!empty($data['fake'])) { break; } # - заблокирован if (!empty($data['user_blocked'])) { break; } # Формируем ссылку: $data['item_link'] = Url::dynamic($data['item_link']); $data['item_link'] .= '?alogin=' . Users::loginAutoHash($data); return $data; } while (false); return false; } /** * Получение данных ОБ для страницы просмотра ОБ * @param int $itemID ID объявления * @param array $opts * @return array */ public function itemDataView($itemID, array $opts = []) { if (empty($itemID)) { return []; } $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); $lang = $opts['lang']; $fields = []; # + category view meta fields foreach (['mtitle', 'mkeywords', 'mdescription', 'msocialtext', 'seotext'] as $v) { $fields[] = 'CL.view_' . $v . ' AS ' . $v; } $fields[] = 'C.view_mtemplate AS mtemplate'; $fields[] = 'IF(I.mcategorysocialtemplate, C.view_msocial, I.msocial) AS msocial'; $fields[] = 'C.view_msocialtemplate AS msocialtemplate'; $data = $this->db->tag('listings-item-data-view', ['fields' => &$fields])->one_array( 'SELECT I.*, CL.title as cat_title, C.settings as cat_settings, U.phone_number, U.phone_number_verified ' . (!empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I, ' . Users::TABLE_USERS . ' U, ' . static::TABLE_CATEGORIES . ' C LEFT JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL ON ' . $this->db->langAnd(false, 'C', 'CL', $lang) . ' WHERE I.id = :id AND I.cat_id = C.id AND I.user_id = U.user_id LIMIT 1', [':id' => $itemID] ); if (!empty($data)) { $this->catsSettingsMerge($data, ['name' => 'cat_settings', 'merge' => false, 'list' => false]); $data['cat_regions'] = $data['cat_settings']['geo']['enabled'] ?? 0; $data['cat_addr'] = $data['cat_regions'] && $data['cat_settings']['geo']['addr'] ?? 0; # форматируем цену ItemPrice::i()->format(['data' => &$data], ['lang' => $lang]); # формируем данные о городе и метро # формируем данные о регионе ОБ $data['city'] = Geo::regionData($data['geo_city']); $data['city_id'] = $data['geo_city']; $data['city_title'] = $data['city']['title'] ?? ''; $data['country'] = Geo::regionData(Geo::regionCountry($data['city'])); $data['country_title'] = $data['country']['title'] ?? ''; if ($data['metro_id']) { $data['metro_data'] = Geo::model()->metroStationsListing(['id' => $data['metro_id']], ['oneArray' => true]); } # city district if (Geo::districtsEnabled() && $data['district_id']) { $data['district_data'] = Geo::model()->districtsListing(['id' => $data['district_id']], ['oneArray' => true]); if (empty($data['district_data'])) { $data['district_id'] = 0; } else { $data['district_data']['title'] = $data['district_data']['title_' . $lang] ?? ''; } } # phones $data['phones'] = (!empty($data['phones']) ? func::unserialize($data['phones']) : []); # + телефон регистрации для объявления от частного лица if (empty($data['company_id']) && Users::registerPhoneContacts() && $data['phone_number'] && $data['phone_number_verified']) { array_unshift($data['phones'], [ 'v' => $data['phone_number'], 'm' => Users::phoneMask($data['phone_number']), ]); } # contacts $data['contacts'] = [ 'contacts' => Users::contactsToArray($data['contacts']), 'phones' => [], ]; if (! empty($data['phones'])) { foreach ($data['phones'] as $v) { $data['contacts']['phones'][] = $v['m']; } } $data['contacts']['has'] = (!empty($data['contacts']['contacts']) || !empty($data['phones'])); # dynprops (cache key => value) $sql = Listings::dp()->prepareSelectFieldsQuery($data['cat_id']); if (! empty($sql)) { $dp = $this->db->tag('listings-item-data-view-dp', ['fields' => &$sql]) ->one_array('SELECT ' . $sql . ' FROM ' . static::TABLE_ITEMS . ' WHERE id = :id', [':id' => $itemID]); if (! empty($dp) && is_array($dp)) { foreach ($dp as $k => $v) { if (! isset($data[$k])) { $data[$k] = $v; } } } } # load item translatable + SEO data $langFieldsExclude = []; if ($data['mcategorytemplate']) { # use category seo settings $langFieldsExclude = array_merge($langFieldsExclude, ['mtitle','mkeywords','mdescription','seotext']); } else { $data['mtemplate'] = 0; # use only item seo settings (without template) } if ($data['mcategorysocialtemplate']) { # use category social settings $langFieldsExclude[] = 'msocialtext'; } else { $data['msocialtemplate'] = 0; # use only item social settings (without template) } $translate = $this->itemLangData($itemID, $lang, ['exclude' => $langFieldsExclude]); foreach ($translate as $f => $value) { $data[$f] = (is_string($value) && $value !== '' ? $value : ($data[$f] ?? '')); } # active services $data['svc'] = explode(',', $data['svc']); } return $data; } /** * Получение данных о переводе для объявления * @param int $itemId * @param string|null $lang one language only * @param array $opts [exclude] * @return array */ public function itemLangData($itemId, ?string $lang = null, array $opts = []) { $filter = ['id' => $itemId]; if ($lang) { $filter['lang'] = $lang; } $fields = $opts['fields'] ?? array_merge($this->langItem, $this->langItemSEO); if ($opts['exclude'] ?? false) { foreach ($opts['exclude'] as $field) { unset($fields[$field]); } } $data = []; $this->db->langSelect($filter, $data, $fields, static::TABLE_ITEMS_LANG, $lang); return $data; } /** * Получение данных о нескольких объявлениях по фильтру * @param array $filter параметры фильтра * @param array $fields требуемые поля * @param array $opts: * string 'context' контекст вызова функции * string 'prefix' префикс таблицы * bool 'oneColumn' один столбец из таблицы * string 'groupKey' поле для группировки данных в массиве, по умолчанию: 'id' * string|array 'groupBy' условие запроса GROUP BY * string|array 'orderBy' условие запроса ORDER BY * int|string|array 'limit' лимит выборки, например: 15 * callable 'iterator' функция-итератор * @return mixed */ public function itemsDataByFilter(array $filter, array $fields = [], array $opts = []) { if (empty($fields)) { $fields = ['*']; } if (! is_array($fields)) { $fields = [$fields]; } # default options: $opts = $this->defaults($opts, [ 'context' => '?', 'prefix' => '', 'oneColumn' => false, 'groupKey' => 'id', 'groupBy' => false, 'orderBy' => false, 'limit' => false, 'iterator' => false, 'ttl' => 0, ]); $this->db->tag('listings-items-data-by-filter', [ 'filter' => &$filter, 'fields' => &$fields, 'options' => &$opts, ]); $select = $this->db->select_prepare(static::TABLE_ITEMS, $fields, $filter, $opts); if (! empty($opts['iterator'])) { $this->db->select_iterator($select['query'], $select['bind'], $opts['iterator']); } elseif ($opts['oneColumn']) { return $this->db->select_one_column($select['query'], $select['bind'], $opts['ttl']); } elseif (!empty($opts['groupKey'])) { return $this->db->select_key($select['query'], $opts['groupKey'], $select['bind'], $opts['ttl']); } else { return $this->db->select($select['query'], $select['bind'], $opts['ttl']); } } /** * Поиск ID объявлений по фильтру * @param array $filter параметры фильтра * @param array $opts: * string 'context' контекст вызова функции * bool 'count' только подсчет кол-ва * string|array 'groupBy' условие запроса GROUP BY * string|array 'orderBy' условие запроса ORDER BY * int 'limit' лимит выборки, например: 15 * int 'offset' пропуск результатов выборки * @return array|int список ID найденных объявлений или кол-во найденных исходя из фильтра */ public function itemsSearch(array $filter, array $opts = []) { $opts = $this->defaults($opts, [ 'returnQuery' => false, 'returnFilter' => false, 'context' => '?', 'count' => false, 'groupBy' => false, 'orderBy' => false, 'limit' => 0, 'offset' => 0, 'ttl' => 0, ]); $this->db->tag('listings-items-id-by-filter', [ 'filter' => &$filter, 'options' => &$opts, ]); # Sphinx: if (isset($filter[':query']) && ItemsSearchSphinx::enabled() && ! isset($filter['svc'])) { $data = Listings::itemsSearchSphinx()->searchItems($filter[':query'], $filter, $opts['count'], $opts['limit'], $opts['offset'], $opts['orderBy']); if ($data !== false) { return $data; } } # MySQL: if (isset($filter[':query'])) { if (Listings::translate()) { $filter[':query'] = [ '(title LIKE (:query) OR title_translates LIKE (:query) OR descr LIKE (:query) OR descr_translates LIKE (:query) OR phones LIKE (:query))', ':query' => '%' . $filter[':query'] . '%', ]; } else { $filter[':query'] = [ '(title LIKE (:query) OR descr LIKE (:query) OR phones LIKE (:query))', ':query' => '%' . $filter[':query'] . '%', ]; } } # фильтр по категории if (isset($filter[':cat-filter'])) { $catFilter = $this->catPathFilter($filter[':cat-filter']); if ($catFilter !== false) { $filter[':cat-filter'] = $catFilter; } else { unset($filter[':cat-filter']); } } # фильтр по региону if (isset($filter[':region-filter'])) { $regionID = $filter[':region-filter']; $regionFilter = []; if ($regionID > 0 && ($regionData = Geo::regionData($regionID))) { $regionFilter[] = ['ANY']; $searchDelivery = $this->config('listings.search.delivery', true, TYPE_BOOL); if ($regionData['numlevel'] == 1) { $regionFilter[] = [$regionData['id']]; } else { $r = []; foreach ($regionData['parents'] as $v) { $r[] = $v['id']; } $r[] = $regionData['id']; $regionFilter[] = $r; if ($searchDelivery) { $regionFilter[] = [$regionData['parents'][1]['id'] ?? 0, 'ANY']; } } } if (! empty($regionFilter)) { $regionFilterQuery = []; $regionFilterBind = []; $i = 1; foreach ($regionFilter as $v) { $regionFilterQuery[] = 'geo_path LIKE :regionQuery' . $i; $regionFilterBind[':regionQuery' . $i] = '-' . join('-', $v) . '-%'; $i++; } $filter[':region-filter'] = ['(' . join(' OR ', $regionFilterQuery) . ')'] + $regionFilterBind; } if (! isset($filter[':cat-filter'])) { $filter[':cat-filter'] = $this->catPathFilter(0, true); } } if (isset($filter['is_publicated'], $filter['status'])) { # Оптимизация под индексы $filter = $this->itemsSearchOptimize($filter); } # Оптимизация дин. свойств if (isset($filter[':dp'])) { if (is_string($filter[':dp'])) { $filter[':dp'] = 'id IN (SELECT id FROM ' . static::TABLE_ITEMS . ' FORCE INDEX (search_dp) WHERE ' . $filter[':dp'] . ' )'; } elseif (is_array($filter[':dp'])) { foreach ($filter[':dp'] as $k => $v) { if ($k == 0 && is_string($v)) { $filter[':dp'][0] = 'id IN (SELECT id FROM ' . static::TABLE_ITEMS . ' FORCE INDEX (search_dp) WHERE ' . $filter[':dp'][0] . ' )'; break; } } } } if (isset($filter['svc'])) { $filter[':svc'] = [ 'id IN ( SELECT item_id FROM ' . ItemServiceStatus::TABLE . ' WHERE group_key = :group_key AND service_key = :service_key AND service_status = :active) ', ':group_key' => 'listings', ':service_key' => $filter['svc'], ':active' => Service::STATUS_ACTIVE, ]; unset($filter['svc']); } if ($opts['returnFilter']) { return $filter; } if ($opts['offset'] > 0) { $opts['limit'] = [$opts['offset'], $opts['limit']]; } if ($opts['count']) { unset($opts['limit'], $opts['offset']); return $this->db->select_rows_count(static::TABLE_ITEMS, $filter, $opts); } $fields = ['id']; if ($opts['returnQuery'] && is_array($opts['returnQuery'])) { $fields = $opts['returnQuery']; } $select = $this->db->select_prepare(static::TABLE_ITEMS, $fields, $filter, $opts); if ($opts['returnQuery']) { return $select; } return $this->db->select_one_column($select['query'], $select['bind'], $opts['ttl']); } /** * Оптимизируем запрос поиска с учетом индексов * @param array $filter * @param array $opts * @return array */ protected function itemsSearchOptimize(array $filter, array $opts = []) { # нулевые значения для индекса # search: is_publicated, status, cat_path, geo_path, cat_type, imgcnt, owner_type, price_search, addr_lat, district_id, metro_id $any = [ 'is_publicated' => ['>=',0], 'status' => ['>=',0], ':cat-filter' => $this->catPathFilter(0, true), ':region-filter' => ['geo_path LIKE :regionQueryAny', ':regionQueryAny' => '-%'], 'cat_type' => ['>=',static::TYPE_OFFER], 'imgcnt' => ['>=',0], 'owner_type' => ['>=',0], ':price' => ['price_search >= :priceAny', ':priceAny' => 0], 'addr_lat' => ['<',91], 'district_id' => ['>=',0], 'metro_id' => ['>=',0], ]; # отбросим хвостовые нулевые фильтры $any = array_reverse($any); foreach ($any as $k => $v) { if (isset($filter[$k])) { break; } unset($any[$k]); } $any = array_reverse($any); $result = []; foreach ($any as $k => $v) { $result[ $k ] = (isset($filter[$k]) ? $filter[$k] : $v); unset($filter[$k]); } if (! empty($filter)) { foreach ($filter as $k => $v) { $result[$k] = $v; } } return $result; } /** * Обновляем данные об объявлениях * @param array $itemsID ID объявлений * @param array $data данные * @return int кол-во обновленных объявлений */ public function itemsSave(array $itemsID, array $data) { return $this->itemsUpdateByFilter($data, ['id' => $itemsID]); } /** * Обновление данных объявлений по фильтру * @param array $update обновляемые данные * @param array $filter параметры фильтра * @param array $opts: * string 'context' контекст вызова функции * array 'bind' доп. параметры подставляемые в запрос * string|array 'orderBy' условие запроса ORDER BY * int|string|array 'limit' лимит выборки, например: 15 * array 'cryptKeys' шифруемые столбцы * @return int кол-во обновленных объявлений */ public function itemsUpdateByFilter(array $update, array $filter, array $opts = []) { if (empty($update) || empty($filter)) { return 0; } # default options: $opts = $this->defaults($opts, [ 'context' => '?', 'tag' => '', 'bind' => [], 'orderBy' => false, 'limit' => false, 'iterator' => false, 'cryptKeys' => [], ]); $ids = $filter['id'] ?? []; if ($ids) { # проверяем необходимость пересчета счетчиков объявлений $refreshCounters = $this->itemSaveCountersCheck($ids, $update); } # tag if (! empty($opts['tag'])) { $this->db->tag($opts['tag']); } $updated = (int)$this->db->update(static::TABLE_ITEMS, $update, $filter, $opts['bind'], $opts['cryptKeys'], $opts); if ($updated > 0 && $ids) { if (isset($update['status']) || isset($update['deleted'])) { $this->itemsIndexesUpdate($ids, 'is_publicated'); } if (isset($update['moderated']) && $update['moderated'] == 1) { $this->itemSaveModerated($ids); } if (! empty($refreshCounters)) { $this->itemsSaveCountersUpdate($ids, $refreshCounters); } Listings::itemsSearchSphinx()->itemsUpdateByFilter($update, $filter); } return $updated; } /** * Актуализация индексируемых полей статуса объявлений * @param array $itemsID ID объявлений * @param string|bool $indexName название индекса для пересборки или FALSE - все индексы */ public function itemsIndexesUpdate(array $itemsID = [], $indexName = false) { $filter = []; if (!empty($itemsID)) { $filter['id'] = $itemsID; } $opts = ['context' => __FUNCTION__]; # is_publicated: if ($indexName === 'is_publicated' || $indexName === false) { $this->itemsUpdateByFilter([ 'is_publicated' => 0, ], $filter + [ 'is_publicated' => 1, ], $opts); $filterCopy = $filter; $filterCopy['status'] = static::STATUS_PUBLICATED; $filterCopy['deleted'] = 0; if (Listings::premoderation()) { $filterCopy['moderated'] = ['>', 0]; } $this->itemsUpdateByFilter([ 'is_publicated' => 1, ], $filterCopy, $opts); } # is_moderating: if ($indexName === 'is_moderating' || $indexName === false) { $this->itemsUpdateByFilter([ 'is_moderating' => 0, ], $filter + [ 'is_moderating' => 1, ], $opts); $filterCopy = $filter; $filterCopy['moderated'] = ['!=', 1]; $filterCopy['status'] = ['NOT IN', [static::STATUS_NOTACTIVATED, static::STATUS_DELETED]]; $filterCopy['deleted'] = 0; $this->itemsUpdateByFilter([ 'is_moderating' => 1, ], $filterCopy, [ 'context' => $opts['context'], ]); } # cat_path: if ($indexName === 'cat_path') { $fieldPrefix = 'cat_id'; $fieldsList = []; for ($i = 1; $i <= Listings::catsDepthLimit(); $i++) { $fieldsList[] = $fieldPrefix . $i; } $this->itemsUpdateByFilter([ 'cat_path = CONCAT(:sep, CONCAT_WS(:sep, ' . join(', ', $fieldsList) . '), :sep)', ], $filter + [ 'cat_id > 0', ], [ 'context' => $opts['context'], 'bind' => [':sep' => '-'], ]); } # geo_path: if ($indexName === 'geo_path') { $fieldPrefix = 'geo_region'; $fieldsList = []; $deep = Geo::maxDeep(); for ($i = 1; $i <= $deep; $i++) { $fieldsList[] = $fieldPrefix . $i; } $this->itemsUpdateByFilter([ 'geo_path = CONCAT(:sep, CONCAT_WS(:sep, ' . join(', ', $fieldsList) . '), :sep)', ], $filter + [ 'regions_delivery = 0', ], [ 'context' => $opts['context'], 'bind' => [':sep' => '-'], ]); $this->itemsUpdateByFilter([ 'geo_path = CONCAT(:sep, geo_region1, :sep, :any, :sep)', ], $filter + [ 'regions_delivery = 1', ], [ 'context' => $opts['context'], 'bind' => [':sep' => '-', ':any' => 'ANY'], ]); } } /** * Отвязываем объявления от компании (при его удалении) * @param int $companyId ID компании * @param int $userId ID пользователя (владельца компании) * @return int кол-во затронутых объявлений */ public function itemsUnlinkCompany($companyId, $userId) { if (empty($companyId) || empty($userId)) { return 0; } # index: users return $this->itemsUpdateByFilter(['company_id' => 0], [ 'user_id' => $userId, 'company_id' => $companyId, ]); } /** * Привязываем объявления пользователя к компании * @param int $userId ID пользователя * @param int $companyId ID компании * @return int кол-во затронутых объявлений */ public function itemsLinkCompany($userId, $companyId) { if (empty($userId) || empty($companyId)) { return 0; } # index: users return $this->itemsUpdateByFilter(['company_id' => $companyId], [ 'user_id' => $userId, ]); } /** * Получаем общее кол-во объявлений, ожидающих модерации * @return int */ public function itemsModeratingCounter() { $filter = ['is_moderating' => 1]; # index: moderating $this->db->tag('listings-items-moderating-count', ['filter' => &$filter]); return $this->itemsCount($filter); } /** * Получаем общее кол-во опубликованных объявлений * @param array $filter доп. фильтр * @return int */ public function itemsPublicatedCounter(array $filter = []) { # TODO: index $filter['no_region'] = 0; if (! isset($filter['cat_id'])) { $filter['cat_id'] = 0; } if (isset($filter['geo'])) { if (($filter['geo']['region']['numlevel'] ?? 0) == 1) { $filter['region_id'] = $filter['geo']['region']['id'] ?? 0; unset($filter['geo']); $data = $this->itemsCountByFilter($filter, ['cat_id', 'items'], false); $sum = 0; foreach ($data as $v) { $sum += $v['items']; } return $sum; } else { $filter['region_id'] = $filter['geo']['region']['id'] ?? 0; $country = $filter['geo']['region']['parents'][1]['id'] ?? 0; unset($filter['geo']); $filter['delivery'] = 0; $sum = (int)$this->itemsCountByFilter($filter); if ($country) { $filter['region_id'] = $country; $filter['delivery'] = 1; $sum += (int)$this->itemsCountByFilter($filter); } return $sum; } } else { $filter['region_id'] = 0; $filter['delivery'] = 0; } return (int)$this->itemsCountByFilter($filter); } /** * Публикация нескольких объявлений по фильтру * @param array $filter фильтр требуемых объявлений * @param string $to дата окончания публикации * @return int кол-во затронутых объявлений */ public function itemsPublicate(array $filter, ?string $to = null) { if (empty($filter)) { return 0; } $result = 0; $now = $this->now(); $publicatedPeriod = $to ?? Listings::itemPublicationPeriod()->publishTo(); $update = [ 'status_prev = status', 'is_publicated' => 1, 'status' => static::STATUS_PUBLICATED, 'publicated' => $now, 'publicated_to' => $publicatedPeriod, # от текущей даты ]; $timeout = $this->config('listings.publicate.topup.timeout', 7, TYPE_UINT); if ($timeout > 0) { # Публикуем + поднимаем $updateUp = $update; $updateUp['publicated_order'] = $now; $filterUp = $filter; $filterUp[] = 'DATEDIFF(:now, publicated_order) >= :days'; $result += $this->itemsUpdateByFilter( $updateUp, $filterUp, [ 'bind' => [ ':now' => $now, ':days' => $timeout, ], 'context' => 'items-publicate-topup', ] ); } # Публикуем оставшиеся $result += $this->itemsUpdateByFilter($update, $filter, [ 'context' => 'items-publicate', ]); return $result; } /** * Продление нескольких объявлений по фильтру * @param array $filter фильтр требуемых объявлений * @return int кол-во затронутых объявлений */ public function itemsRefresh(array $filter) { if (empty($filter)) { return 0; } $updated = 0; $this->itemsDataByFilter($filter, ['id', 'publicated_to'], [ 'iterator' => function ($item) use (&$updated) { $res = $this->itemSave($item['id'], [ # от даты завершения публикации объявления 'publicated_to' => Listings::itemPublicationPeriod()->refreshTo($item['publicated_to']), ]); if (! empty($res)) { $updated++; } }, 'context' => 'items-refresh', ]); return $updated; } /** * Снятие нескольких объявлений с публикации по фильтру * @param array $filter фильтр требуемых объявлений * @return int кол-во затронутых объявлений */ public function itemsUnpublicate(array $filter) { if (empty($filter)) { return 0; } return $this->itemsUpdateByFilter([ 'status_prev = status', 'status' => static::STATUS_PUBLICATED_OUT, 'publicated_to' => $this->now(), 'is_publicated' => 0, ], $filter, [ 'context' => 'items-unpublicate', ]); } /** * Удаление нескольких объявлений одного пользователя * @param array $itemsID ID удаляемых объявлений * @param bool $updateUserCounter выполнять актуализацию счетчика объявлений пользователя * @return int кол-во удаленных объявлений */ public function itemsDelete(array $itemsID, bool $updateUserCounter = true) { if (empty($itemsID)) { return 0; } $cats = []; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $cats[] = 'I.cat_id' . $i; } $deep = Geo::maxDeep(); $reg = []; for ($i = 1; $i <= $deep; $i++) { $reg[] = 'I.geo_region' . $i; } $fields = []; $data = $this->db->tag('listings-items-delete-data', ['fields' => &$fields])->select( 'SELECT I.id, I.user_id, I.status, I.cat_id, ' . join(', ', $cats) . ', I.cat_type, I.imgcnt, I.claims_cnt, I.messages_total, I.moderated, I.geo_city, ' . join(', ', $reg) . ', I.regions_delivery, I.created, U.activated as user_activated, C.settings ' . (!empty($fields) ? ',' . join(',', $fields) : '') . ' FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON I.cat_id = C.id LEFT JOIN ' . Users::TABLE_USERS . ' U ON I.user_id = U.user_id WHERE ' . $this->prepareIN('I.id', $itemsID) ); if (empty($data)) { return 0; } $this->catsSettingsMerge($data, ['merge' => false]); $userItemsCounter = 0; $claimsCounter = false; $moderationCounter = false; $messagesUnlink = false; $itemsID = []; $images = Listings::itemImages(); $hookDelete = $this->app->hooksAdded('listings.item.delete'); foreach ($data as &$v) { $v['geo_regions'] = $v['settings']['geo']['enabled'] ?? 0; # удаляем изображения if ($v['imgcnt'] > 0) { $images->setRecordID($v['id']); $images->deleteAllImages(false); } if ($updateUserCounter && $v['status'] != static::STATUS_NOTACTIVATED) { $userItemsCounter++; } if ($v['messages_total'] > 0) { $messagesUnlink = true; } if ($v['claims_cnt'] > 0) { $claimsCounter = true; } if ($v['moderated'] != 1) { $moderationCounter = true; } $itemsID[] = $v['id']; if ($v['status'] == static::STATUS_PUBLICATED && ( Listings::premoderation() ? $v['moderated'] > 0 : true)) { $this->itemsCountersUpdate($v, -1); } if ($hookDelete) { $this->app->hook('listings.item.delete', $v); } } unset($v); $this->itemsFavDelete(0, $itemsID); $this->db->delete(static::TABLE_ITEMS_VIEWS, ['item_id' => $itemsID]); $this->db->delete(static::TABLE_ITEMS_CLAIMS, ['item_id' => $itemsID]); $this->db->delete(static::TABLE_ITEMS_LANG, ['id' => $itemsID]); if (Listings::commentsEnabled()) { # удаляем комментарии $this->db->delete(static::TABLE_ITEMS_COMMENTS, ['item_id' => $itemsID]); # пересчитываем кол-во непромодерированных комментариев Listings::itemComments()->updateUnmoderatedAllCounter(null); } $res = $this->db->delete(static::TABLE_ITEMS, ['id' => $itemsID]); if (!empty($res)) { # удаляем связь сообщений внутренней почты с удаляемыми объявлениями if ($messagesUnlink) { InternalMail::model()->unlinkMessagesItemsID($itemsID); } # актуализируем счетчик необработанных жалоб if ($claimsCounter) { Listings::claimsCounterUpdate(null); } # актуализируем счетчик объявлений пользователя if ($updateUserCounter && $userItemsCounter > 0) { $itemData = reset($data); Users::model()->userCounterSave($itemData['user_id'], 'items', -$userItemsCounter); } # актуализируем счетчик "на модерации" if ($moderationCounter) { Listings::moderationCounterUpdate(null); } } return intval($res); } /** * Полное удаление всех объявлений пользователя * @param int $userID ID пользователя * @param array $opts доп. параметры: * 'markDeleted' - только пометить как удаленные * @return int кол-во затронутых объявлений */ public function itemsDeleteByUser($userID, array $opts = []) { $total = 0; if (empty($userID)) { return $total; } if (! empty($opts['markDeleted'])) { $total = $this->itemsUpdateByFilter([ 'publicated_to' => $this->now(), # помечаем дату снятия с публикации 'status_prev = status', 'status_changed' => $this->now(), 'status' => static::STATUS_DELETED, 'deleted' => 1, 'is_publicated' => 0, 'is_moderating' => 0, ], [ 'user_id' => $userID, ], [ 'context' => 'user-items-delete', ]); } else { $data = []; $this->db->tag('listings-items-delete-by-user')->select_iterator( 'SELECT id FROM ' . static::TABLE_ITEMS . ' WHERE user_id = :user_id', [':user_id' => $userID], function ($row) use (&$data, &$total) { $data[] = $row['id']; if (count($data) > 100) { $total += $this->itemsDelete($data, true); $data = []; } } ); if (! empty($data)) { $total += $this->itemsDelete($data, true); } } return $total; } /** * Получаем ID избранных объявлений пользователя * @param int $userID ID пользователя * @param array $onlyID фильтр по ID объявлений * @return array|mixed */ public function itemsFavData($userID, array $onlyID = []) { if (empty($userID)) { return []; } $filter = [ 'user_id' => $userID, ]; if (!empty($onlyID)) { $filter['item_id'] = $onlyID; } return $this->db->tag('listings-items-fav-data')->select_rows_column( static::TABLE_ITEMS_FAV, 'item_id', $filter, [ 'groupBy' => 'item_id', ] ); } /** * Сохранение избранных объявлений пользователя * @param int $userID ID пользователя * @param array $itemsID ID объявлений * @return int|mixed кол-во сохраненных объявлений или FALSE */ public function itemsFavSave($userID, array $itemsID) { if (empty($userID) || empty($itemsID)) { return 0; } $data = []; foreach ($itemsID as $id) { $data[] = [ 'item_id' => $id, 'user_id' => $userID, ]; } return $this->db->multiInsert(static::TABLE_ITEMS_FAV, $data); } /** * Удаление избранных объявлений (пользователя) * @param int $userID ID пользователя или 0 * @param int|array|bool $itemID ID объявления(-ний) или FALSE (всех избранных объявлений) * @return mixed */ public function itemsFavDelete($userID, $itemID = false) { $filter = []; if (! empty($userID)) { $filter['user_id'] = $userID; } if ($itemID !== false) { $filter['item_id'] = $itemID; } if (empty($filter)) { return false; } return $this->db->delete(static::TABLE_ITEMS_FAV, $filter); } /** * Накручиваем счетчик просмотров * @param int $itemID ID объявления * @param string $viewType тип просмотра: 'item'=>просмотр ОБ, 'contacts'=>просмотр контактов ОБ * @param int $viewsToday текущий счетчик просмотров объявления за сегодня или 0 * @return bool */ public function itemViewsIncrement($itemID, string $viewType, int $viewsToday = 0) { if (empty($itemID) || ! in_array($viewType, ['item', 'contacts'])) { return false; } $date = date('Y-m-d'); $field = $viewType . '_views'; # static::TABLE_ITEMS_VIEWS: # 1. пытаемся вначале обновить статистику # поскольку запись о статистике за сегодня уже может быть создана $res = $this->db->update( static::TABLE_ITEMS_VIEWS, [$field . ' = ' . $field . ' + 1'], ['item_id' => $itemID, 'period' => $date] ); # обновить не получилось if (empty($res)) { # 2. начинаем подсчет статистики за сегодня if (! empty($viewsToday)) { $this->db->update(static::TABLE_ITEMS, [ 'views_today' => 0, ], ['id' => $itemID]); } $res = $this->db->insert(static::TABLE_ITEMS_VIEWS, [ 'item_id' => $itemID, $field => 1, 'period' => $date, ], false); } # static::TABLE_ITEMS: # 3. накручиваем счетчик просмотров Объявления/Контактов за сегодня (+ общий) if (!empty($res)) { $this->db->update( static::TABLE_ITEMS, [ 'views_total = views_total + 1', 'views_today = views_today + 1', 'views_' . $viewType . '_total = views_' . $viewType . '_total + 1', ], ['id' => $itemID] ); } return !empty($res); } /** * Получаем данные о статистике просмотров объявления * @param int $itemID ID объявления * @return array */ public function itemViewsData($itemID) { $result = ['data' => [], 'from' => '', 'to' => '', 'total' => 0, 'today' => 0]; do { if (empty($itemID)) { break; } $data = $this->db->select( 'SELECT SUM(item_views) as item, SUM(contacts_views) as contacts, period FROM ' . static::TABLE_ITEMS_VIEWS . ' WHERE item_id = :id GROUP BY period ORDER BY period ASC', [':id' => $itemID] ); if (empty($data)) { break; } foreach ($data as $k => $v) { $data[$k]['total'] = $v['item'] + $v['contacts']; $data[$k]['date'] = $v['period']; unset($data[$k]['period']); } $itemData = $this->itemData($itemID, ['views_total', 'views_today']); if (empty($itemData)) { break; } $result['total'] = $itemData['views_total']; $result['today'] = $itemData['views_today']; $view = current($data); $result['from'] = $view['date']; # от $from = strtotime($view['date']); $view = end($data); $result['to'] = $view['date']; # до $to = strtotime($view['date']); reset($data); # дополняем днями, за которые статистика отсутствует $day = 86400; $totalDays = (($to - $from) / $day) + 1; if ($totalDays > sizeof($data)) { $dataFull = []; foreach ($data as $v) { $dataFull[$v['date']] = $v; } $dataResult = []; for ($i = $from; $i <= $to; $i += $day) { $date = date('Y-m-d', $i); if (isset($dataFull[$date])) { $dataResult[$date] = $dataFull[$date]; } else { $dataResult[$date] = ['item' => 0, 'contacts' => 0, 'total' => 0, 'date' => $date]; } } unset($dataFull); $data = array_values($dataResult); } $result['data'] = $data; } while (false); return $result; } /** * Актуализация статуса объявлений (cron) * Рекомендуемый период: раз в 10 минут * @return void */ public function itemsCronStatus() { # Удаляем неактивированные объявления по прошествии суток $data = []; $this->db->select_iterator( 'SELECT id FROM ' . static::TABLE_ITEMS . ' WHERE is_publicated = 0 AND status = :status AND activate_expire <= :now', [ ':status' => static::STATUS_NOTACTIVATED, ':now' => $this->now(), ], function ($row) use (&$data) { $data[] = $row['id']; if (count($data) > 100) { $this->itemsDelete($data, false); # email уведомления не отправляем, поскольку email адреса не подтверджались $data = []; } } ); if (!empty($data)) { $this->itemsDelete($data, false); # email уведомления не отправляем, поскольку email адреса не подтверджались } # Снимаем с публикации просроченные объявления $this->itemsUpdateByFilter([ 'status' => static::STATUS_PUBLICATED_OUT, 'status_prev' => static::STATUS_PUBLICATED, 'status_changed' => $this->now(), 'is_publicated' => 0, ], [ 'is_publicated' => 1, 'status' => static::STATUS_PUBLICATED, 'publicated_period > 0', 'publicated_to <= :now', ], [ 'context' => __FUNCTION__, 'bind' => [':now' => $this->now()], ]); # Выполняем пересчет счетчиков объявлений (items): # Типы категорий if (static::CATS_TYPES_EX) { $this->db->exec('UPDATE ' . static::TABLE_CATEGORIES_TYPES . ' SET items = 0'); $this->db->exec( 'UPDATE ' . static::TABLE_CATEGORIES_TYPES . ' T, (SELECT I.cat_type as id, COUNT(I.id) as items FROM ' . static::TABLE_ITEMS . ' I LEFT JOIN ' . Users::TABLE_USERS . ' U ON I.user_id = U.user_id WHERE I.is_publicated = 1 AND I.status = ' . static::STATUS_PUBLICATED . ' AND (U.user_id IS NULL OR U.blocked = 0) AND I.cat_type != 0 GROUP BY I.cat_type) as X SET T.items = X.items WHERE T.id = X.id' ); } # Актуализируем счетчик "на модерации" Listings::moderationCounterUpdate(); } /** * Пересчет счетчиков количества объявлений в виртуальных категориях * @return void */ public function itemsCountersCalculateVirtual() { $this->db->exec( 'DELETE IC FROM ' . static::TABLE_ITEMS_COUNTERS . ' AS IC, ' . static::TABLE_CATEGORIES . ' AS C WHERE C.id = IC.cat_id AND C.virtual_ptr IS NOT NULL' ); $this->db->exec( 'INSERT INTO ' . static::TABLE_ITEMS_COUNTERS . ' (cat_id, region_id, delivery, no_region, items) SELECT C.id, IC.region_id, IC.delivery, IC.no_region, IC.items FROM ' . static::TABLE_ITEMS_COUNTERS . ' IC INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.virtual_ptr = IC.cat_id ON DUPLICATE KEY UPDATE items = IC.items' ); } /** * Полный пересчет счетчиков количества объявлений в категориях по регионам * @return void */ public function itemsCountersCalculate() { $cats = []; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $cats[] = 'cat_id' . $i; } $regs = []; //'geo_region1',... $deep = Geo::maxDeep(); for ($i = 1; $i <= $deep; $i++) { $regs[] = 'geo_region' . $i; } $filter = [ 'is_publicated' => 1, 'status' => static::STATUS_PUBLICATED, ]; if (Listings::premoderation()) { $filter[] = 'moderated > 0'; } $filter = $this->prepareFilter($filter, 'I'); $first = true; $insert = function (&$values) use (&$first) { if (empty($values)) { return; } if ($first) { $this->db->exec('DELETE FROM ' . static::TABLE_ITEMS_COUNTERS); $first = false; } $this->db->exec(' INSERT INTO ' . static::TABLE_ITEMS_COUNTERS . ' (cat_id, region_id, delivery, no_region, items) VALUES ' . join(',', $values) . ' ON DUPLICATE KEY UPDATE items = items + VALUES(items);'); $values = []; }; $values = []; $total = 0; $this->db->select_iterator( 'SELECT ' . join(',', $cats) . ', ' . join(',', $regs) . ', regions_delivery, C.settings, COUNT(*) AS cnt FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON I.cat_id = C.id ' . $filter['where'] . ' GROUP BY cat_id, geo_city, regions_delivery', $filter['bind'], function ($row) use ($cats, $regs, &$values, &$total, &$insert) { $this->catsSettingsMerge($row, ['merge' => false, 'list' => false]); $total += $row['cnt']; foreach ($cats as $c) { if (empty($row[$c])) { continue; } $values[] = '(' . $row[$c] . ', 0, 0, 0, ' . $row['cnt'] . ')'; } if (empty($row['settings']['geo']['enabled'])) { $values[] = '(0, 0, 0, 1, ' . $row['cnt'] . ')'; foreach ($cats as $c) { if (empty($row[$c])) { continue; } $values[] = '(' . $row[$c] . ', 0, 0, 1, ' . $row['cnt'] . ')'; } } elseif ($row['regions_delivery']) { if ($row['geo_region1']) { $values[] = '(0, ' . $row['geo_region1'] . ', 0, 0, ' . $row['cnt'] . ')'; $values[] = '(0, ' . $row['geo_region1'] . ', 1, 0, ' . $row['cnt'] . ')'; foreach ($cats as $c) { if (empty($row[$c])) { continue; } $values[] = '(' . $row[$c] . ', ' . $row['geo_region1'] . ', 1, 0, ' . $row['cnt'] . ')'; } } } else { foreach ($regs as $r) { if (empty($row[$r])) { continue; } $values[] = '(0, ' . $row[$r] . ', 0, 0, ' . $row['cnt'] . ')'; foreach ($cats as $c) { if (empty($row[$c])) { continue; } $values[] = '(' . $row[$c] . ',' . $row[$r] . ', 0, 0, ' . $row['cnt'] . ')'; } } } if (count($values) > 500) { $insert($values); } } ); $values[] = '(0, 0, 0, 0, ' . $total . ')'; $insert($values); } /** * Обновление счетчика количества объявлений на основе данных о редактируемом объявлении * @param array $item данные объявления * @param int $cnt на сколько изменить количество: +N, -N */ protected function itemsCountersUpdate(array $item, int $cnt) { if (empty($cnt)) { return; } $cats = []; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $cats[] = 'cat_id' . $i; } $deep = Geo::maxDeep(); $regs = []; for ($i = 1; $i <= $deep; $i++) { $regs[] = 'geo_region' . $i; } if ($cnt > 0) { # +N $values = []; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $values[] = '(' . $item[$c] . ', 0, 0, 0, ' . $cnt . ')'; } if (empty($item['settings']['geo']['enabled'])) { $values[] = '(0, 0, 0, 1, ' . $cnt . ')'; foreach ($cats as $c) { if (empty($row[$c])) { continue; } $values[] = '(' . $row[$c] . ', 0, 0, 1, ' . $cnt . ')'; } } elseif ($item['regions_delivery']) { if ($item['geo_region1']) { $values[] = '(0, ' . $item['geo_region1'] . ', 0, 0, ' . $cnt . ')'; $values[] = '(0, ' . $item['geo_region1'] . ', 1, 0, ' . $cnt . ')'; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $values[] = '(' . $item[$c] . ', ' . $item['geo_region1'] . ', 1, 0, ' . $cnt . ')'; } } } else { foreach ($regs as $r) { if (empty($item[$r])) { continue; } $values[] = '(0, ' . $item[$r] . ', 0, 0, ' . $cnt . ')'; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $values[] = '(' . $item[$c] . ',' . $item[$r] . ', 0, 0, ' . $cnt . ')'; } } } $values[] = '(0, 0, 0, 0, ' . $cnt . ')'; $this->db->exec(' INSERT INTO ' . static::TABLE_ITEMS_COUNTERS . ' (cat_id, region_id, delivery, no_region, items) VALUES ' . join(',', $values) . ' ON DUPLICATE KEY UPDATE items = items + VALUES(items);'); } else { # -N $cnt = abs($cnt); $where = []; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $where[] = '(cat_id = ' . $item[$c] . ' AND region_id = 0 AND delivery = 0 AND no_region = 0)'; } if (empty($item['settings']['geo']['enabled'])) { $where[] = '(cat_id = 0 AND region_id = 0 AND delivery = 0 AND no_region = 1 )'; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $where[] = '(cat_id = ' . $item[$c] . ' AND region_id = 0 AND delivery = 0 AND no_region = 1 )'; } } elseif ($item['regions_delivery']) { if ($item['geo_region1']) { $where[] = '(cat_id = 0 AND region_id = ' . $item['geo_region1'] . ' AND delivery = 0 )'; $where[] = '(cat_id = 0 AND region_id = ' . $item['geo_region1'] . ' AND delivery = 1 )'; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $where[] = '(cat_id = ' . $item[$c] . ' AND region_id = ' . $item['geo_region1'] . ' AND delivery = 1 )'; } } } else { foreach ($regs as $r) { if (empty($item[$r])) { continue; } $where[] = '(cat_id = 0 AND region_id = ' . $item[$r] . ' AND delivery = 0)'; foreach ($cats as $c) { if (empty($item[$c])) { continue; } $where[] = '(cat_id = ' . $item[$c] . ' AND region_id = ' . $item[$r] . ' AND delivery = 0)'; } } } $data = $this->db->select('SELECT cat_id, region_id, delivery, no_region, items FROM ' . static::TABLE_ITEMS_COUNTERS . ' WHERE ' . join(' OR ', $where)); if (empty($data)) { return; } $where[] = '(cat_id = 0 AND region_id = 0 AND delivery = 0 AND no_region = 0)'; $update = ['WHEN cat_id = 0 AND region_id = 0 AND delivery = 0 AND no_region = 0 THEN items - ' . $cnt]; $delete = []; foreach ($data as $v) { if ($v['items'] > $cnt) { $update[] = ' WHEN cat_id = ' . $v['cat_id'] . ' AND region_id = ' . $v['region_id'] . ' AND delivery = ' . $v['delivery'] . ' AND no_region = ' . $v['no_region'] . ' THEN items - ' . $cnt; } else { $delete[] = '(cat_id = ' . $v['cat_id'] . ' AND region_id = ' . $v['region_id'] . ' AND delivery = ' . $v['delivery'] . ' AND no_region = ' . $v['no_region'] . ')'; } } if (! empty($delete)) { $this->db->exec('DELETE FROM ' . static::TABLE_ITEMS_COUNTERS . ' WHERE ' . join(' OR ', $delete)); } $this->db->exec(' UPDATE ' . static::TABLE_ITEMS_COUNTERS . ' SET items = CASE ' . join(' ', $update) . ' ELSE items END WHERE ' . join(' OR ', $where)); } } /** * Счетчики количества объявлений по фильтру * @param array $filter [cat_id, region_id, delivery] * @param array|bool $fields false - только количество * @param bool $oneArray * @param int $cache кешировать (в секундах) * @return mixed */ public function itemsCountByFilter(array $filter, $fields = false, bool $oneArray = true, int $cache = 0) { $filter = $this->prepareFilter($filter); if (empty($fields)) { return (int)$this->db->one_data('SELECT SUM(items) FROM ' . static::TABLE_ITEMS_COUNTERS . $filter['where'], $filter['bind'], $cache); } else { if ($oneArray) { return $this->db->one_array('SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_ITEMS_COUNTERS . $filter['where'] . ' LIMIT 1', $filter['bind'], $cache); } else { return $this->db->select('SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_ITEMS_COUNTERS . $filter['where'], $filter['bind'], $cache); } } } /** * Помечаем снятые с публикации объявления как удаленные * по прошествии с момента снятия с публикации N дней, указанных в системных настройках * @param int $days * @return void */ public function itemsCronUnpublicatedTimeout(int $days) { if ($days <= 0) { return; } # Помечаем снятые с публикации объявления как удаленные $this->itemsUpdateByFilter([ 'status' => static::STATUS_DELETED, 'status_prev' => static::STATUS_PUBLICATED_OUT, 'status_changed' => $this->db->now(), ], [ 'is_publicated' => 0, 'status' => static::STATUS_PUBLICATED_OUT, 'status_changed < :date', ], [ 'context' => __FUNCTION__, 'bind' => [':date' => date('Y-m-d H:i:s', strtotime('- ' . $days . ' days'))], ]); } /** * Полное удаление удаленных пользователем объявлений через X дней после окончания публикации * @param int $days * @return void */ public function itemsCronDelete(int $days) { if ($days <= 0) { return; } $data = []; $this->db->tag('listings-items-cron-delete')->select_iterator( 'SELECT id FROM ' . static::TABLE_ITEMS . ' WHERE is_publicated = 0 AND status = :status AND publicated_to < :date', [ ':status' => static::STATUS_DELETED, ':date' => date('Y-m-d H:i:s', strtotime('- ' . $days . ' days')), ], function ($row) use (&$data) { $data[] = $row['id']; if (count($data) > 100) { $this->itemsDelete($data, false); $data = []; } } ); if (! empty($data)) { $this->itemsDelete($data, false); } } /** * Помечаем снятые с публикации объявления как удаленные для неактивных аккаунтов пользователей. * @param int $days кол дней * @return void */ public function itemsCronDeleteInactiveUsers(int $days) { if (! $days) { return; } $date = strtotime('-' . $days . ' days'); $now = $this->now(); $ids = []; $this->db->select_iterator( 'SELECT I.id FROM ' . static::TABLE_ITEMS . ' I, ' . Users::TABLE_USERS_STAT . ' S WHERE I.user_id = S.user_id AND I.is_publicated = 0 AND I.status = :status AND S.last_login < :date', [ ':status' => static::STATUS_PUBLICATED_OUT, ':date' => date('Y-m-d', $date), ], function ($row) use (&$ids, $now) { $ids[] = $row['id']; if (count($ids) > 100) { $this->db->update(static::TABLE_ITEMS, [ 'status' => static::STATUS_DELETED, 'status_changed' => $now, 'is_publicated' => 0, 'is_moderating' => 0, ], ['id' => $ids]); $ids = []; } } ); if (! empty($ids)) { $this->db->update(static::TABLE_ITEMS, [ 'status' => static::STATUS_DELETED, 'status_changed' => $now, 'is_publicated' => 0, 'is_moderating' => 0, ], ['id' => $ids]); } } /** * Актуализация статистики объявлений (cron) * Рекомендуемый период: раз в сутки (в 00:00) * @param int $months * @return void */ public function itemsCronViews(int $months = 1) { # Обнуляем статистику просмотров за сегодня $this->itemsUpdateByFilter(['views_today' => 0], [], ['context' => __FUNCTION__]); # Удаляем историю просмотров старше X месяцев $this->db->exec( 'DELETE FROM ' . static::TABLE_ITEMS_VIEWS . ' WHERE period < DATE_SUB(:now, INTERVAL ' . $months . ' MONTH)', [':now' => $this->now()] ); } /** * Данные об объявлениях для "Уведомления о завершении публикации объявлений" * @param array $days список дней за сколько необходимо отправить уведомление * @param int $limit ограничение на выборку * @param string $date дата в формате "Y-m-d" * @return array */ public function itemsCronUnpublicateSoon(array $days, int $limit, string $date) { if (empty($days) || empty($date)) { return []; } if ($limit <= 0) { $limit = 100; } $filter = [ 'I.is_publicated = 1', 'I.status = ' . static::STATUS_PUBLICATED, 'DATEDIFF(I.publicated_to,STR_TO_DATE(:date, :format)) IN (' . join(',', $days) . ')', 'E.item_id IS NULL', 'U.user_id = I.user_id', 'US.user_id = I.user_id', 'U.blocked = 0', 'U.activated = 1', 'U.enotify & ' . Users::ENOTIFY_NEWS, ]; $filter = $this->prepareFilter($filter, '', [ ':date' => $date, ':type' => self::ITEMS_ENOTIFY_UNPUBLICATESOON, ':format' => '%Y-%m-%d', ]); $data = $this->db->select_key('SELECT I.id as item_id, I.title as item_title, I.link as item_link, U.email, U.name, I.user_id, U.user_id_ex, US.last_login, U.lang, DATEDIFF(I.publicated_to,STR_TO_DATE(:date, :format)) as days, COUNT(I.id) AS cnt, GROUP_CONCAT(I.id) AS items FROM ' . static::TABLE_ITEMS . ' as I LEFT JOIN ' . static::TABLE_ITEMS_ENOTIFY . ' E ON E.item_id = I.id AND sended = :date AND message_type = :type, ' . Users::TABLE_USERS . ' as U, ' . Users::TABLE_USERS_STAT . ' as US ' . $filter['where'] . ' GROUP BY I.user_id ' . $this->db->prepareLimit(0, $limit), 'user_id', $filter['bind']); if (empty($data)) { $data = []; } return $data; } /** * Уведомления о завершении срока публикации: * Работа со списком объявлений отправленных уведомлений о завершении срока публикации * @param array $itemsID ID объявлений * @param string $date дата в формате "Y-m-d" * @return bool */ public function itemsCronUnpublicateSended(array $itemsID, string $date) { $data = $this->db->select_rows_column(static::TABLE_ITEMS_ENOTIFY, 'item_id', [ 'message_type' => self::ITEMS_ENOTIFY_UNPUBLICATESOON, 'item_id' => $itemsID, 'sended' => $date, ]); if (empty($data)) { $data = false; } $insert = []; foreach ($itemsID as $v) { if ($data && in_array($v, $data)) { continue; } $insert[] = [ 'message_type' => self::ITEMS_ENOTIFY_UNPUBLICATESOON, 'item_id' => $v, 'sended' => $date, ]; } if (! empty($insert)) { $this->db->multiInsert(static::TABLE_ITEMS_ENOTIFY, $insert); return false; } return true; } public function itemsEnotifyClear(string $type, string $date) { if (empty($type) || empty($date)) { return false; } return $this->db->delete(static::TABLE_ITEMS_ENOTIFY, [ 'sended < :date', 'message_type' => $type, ], [ ':date' => $date, ]); } /** * Очистка списка объявлений для которых выполнялась отправка уведомлений * о завершении срока публикации за указанную дату. * @param string $date дата в формате "Y-m-d" * @return bool */ public function itemsCronUnpublicateClearLast(string $date) { return $this->itemsEnotifyClear(self::ITEMS_ENOTIFY_UNPUBLICATESOON, $date); } /** * Получаем список объявлений исходя из даты завершения публикации * @param int $userID ID пользователя * @param int $day дата завершения публикации в Unix формате * @return array */ public function itemsUserUnpublicateDay($userID, int $day) { return $this->itemsDataByFilter([ 'user_id' => $userID, 'company_id' => ['>=', 0], 'is_publicated' => 1, 'status' => static::STATUS_PUBLICATED, ':from' => ['publicated_to >= :from', ':from' => date('Y-m-d 00:00:00', $day)], ':to' => ['publicated_to <= :to', ':to' => date('Y-m-d 23:59:59', $day)], ], ['id','company_id'], [ 'groupKey' => false, ]); } /** * Получение объявлений для формирования файла Sitemap.xml (cron) * @param array $filter фильтр * @param string $priority приоритетность url * @return callable callback-генератор строк вида array [['l'=>'url страницы','m'=>'дата последних изменений'],...] */ public function itemsSitemapXmlData(array $filter = [], string $priority = '') { $filter['is_publicated'] = 1; $filter['status'] = static::STATUS_PUBLICATED; return function ($count = false, callable $callback = null) use ($filter, $priority) { if ($count) { return $this->itemsCount($filter); } else { $filter = $this->prepareFilter($filter, '', [ ':format' => '%Y-%m-%d', ]); if (Listings::translate()) { $alt = SEO::sitemapXMLAltLangs(SEO::SITEMAP_LANG_TRANSLATIBLE, SEO::SITEMAP_LANG_ALL); } else { $alt = SEO::sitemapXMLAltLangs(SEO::SITEMAP_LANG_ALL); } $this->db->tag('listings-items-sitemap-xml-data', ['filter' => &$filter])->select_iterator( 'SELECT link as l, DATE_FORMAT(modified, :format) as m FROM ' . static::TABLE_ITEMS . ' ' . $filter['where'] . ' ORDER BY publicated_order DESC', $filter['bind'], function (&$item) use (&$callback, $priority, $alt) { if (! empty($alt)) { $item['alt'] = []; foreach ($alt as $l) { $item['alt'][$l] = Url::dynamic($item['l'], [], ['lang' => $l]); } } $item['l'] = Url::dynamic($item['l']); if (! empty($priority)) { $item['p'] = $priority; } $callback($item); } ); } return false; }; } /** * Получение текущей позиции опубликованного объявления в категории * @param int $itemID ID объявления * @param int $categoryID ID основной категории * @param int $limit ограничение поиска в списке * @return int текущая позиция объявления */ public function itemPositionInCategory($itemID, int $categoryID = 0, int $limit = 15) { if ($limit <= 0) { $limit = 30; } $position = 0; do { if ($categoryID == 0) { $item = $this->itemData($itemID, ['cat_id1']); if (empty($item['cat_id1'])) { break; } $categoryID = $item['cat_id1']; } # получаем список первых объявлений в категории $itemsID = $this->itemsSearch([ 'is_publicated' => 1, 'status' => static::STATUS_PUBLICATED, ':cat-filter' => $categoryID, ], [ 'orderBy' => 'publicated_order DESC', 'limit' => $limit, 'context' => 'item-category-position', ]); if (empty($itemsID)) { break; # нет среди первых } # ищем $itemID среди найденных $i = 1; foreach ($itemsID as $id) { if ($id == $itemID) { $position = $i; break; } $i++; } } while (false); return $position; } /** * Конвертирование цен объявлений в валюту по умолчанию * @return void */ public function itemsDefaultCurrency() { $defaultID = Currency::id(); $this->itemsUpdateByFilter([ 'price = price_search', 'price_curr' => $defaultID, ], [ 'price_curr != :default', ], [ 'context' => __FUNCTION__, 'bind' => [':default' => $defaultID], ]); } /** * Перестраиваем URL всех объявлений * @return int кол-во затронутых объявлений */ public function itemsLinksRebuild() { $total = 0; $last = 0; $deep = Geo::maxDeep(); $fields = []; for ($i = 1; $i <= $deep; $i++) { $fields[] = 'I.geo_region' . $i; } $fields = join(', ', $fields); do { $data = $this->db->select('SELECT I.id, I.keyword, I.link, C.keyword as cat_keyword, C.landing_url, C.settings, I.regions_delivery, I.geo_path, I.geo_city, ' . $fields . ' FROM ' . static::TABLE_ITEMS . ' I INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = I.cat_id WHERE I.id > :id ORDER BY I.id LIMIT 10 ', [':id' => $last]); if (empty($data)) { break; } $this->catsSettingsMerge($data, ['merge' => false]); foreach ($data as $v) { $last = $v['id']; $update = []; $linkData = [ 'id' => $v['id'], 'keyword' => $v['keyword'], 'event' => 'links-rebuild', 'category' => $v['cat_keyword'], 'landing_url' => $v['landing_url'], 'no_region' => empty($v['settings']['geo']['enabled']), ]; if ($v['geo_city']) { $geo = Geo::regionParentsCache($v['geo_city']); $linkData = array_merge($linkData, $geo['keys'] ?? []); for ($i = 1; $i <= $deep; $i++) { $name = 'geo_region' . $i; if (empty($geo['db'][$name])) { if (! empty($v[$name])) { $update[$name] = 0; } } else { if ($v[$name] != $geo['db'][$name]) { $update[$name] = $geo['db'][$name]; } } } } $link = Listings::url('item.view', $linkData, true); if ($v['link'] != $link) { $update['link'] = $link; } $path = Listings::itemGeoPath($v['settings']['geo']['enabled'] ?? 0, $v['regions_delivery'], $geo['db'] ?? []); if ($v['geo_path'] != $path) { $update['geo_path'] = $path; } if (! empty($update)) { $res = $this->db->update(static::TABLE_ITEMS, $update, ['id' => $v['id']]); if (!empty($res)) { $total++; } } } } while (true); return $total; } /** * Подсчет кол-ва объявлений по фильтру * @param array $filter * @param array $options * @return int */ public function itemsCount(array $filter = [], array $options = []): int { return $this->db->select_rows_count(static::TABLE_ITEMS, $filter, $options); } /** * Проверка структуры категорий. Объявления могут быть в категории, не содержащей подкатегорий. * Если в категории есть и подкатегории и объявления, то объявленея будут перенесены в первую дочернюю категорию не содержащую подкатегорий. * @return int */ public function itemsCatsRebuild() { $items = $this->db->select_key( 'SELECT cat_id, count(*) AS cnt FROM ' . static::TABLE_ITEMS . ' GROUP BY cat_id', 'cat_id' ); $cats = $this->db->select_key('SELECT id, pid, numlevel, 0 AS cnt FROM ' . static::TABLE_CATEGORIES, 'id'); foreach ($cats as $v) { $pid = $v['pid']; if (empty($pid)) { continue; } if (! isset($cats[$pid])) { $this->errors->set('Incorrect cat pid ' . $v['id']); return 0; } $cats[$pid]['cnt']++; }; $children = function ($cat) use (&$cats) { $result = []; foreach ($cats as $v) { if ($v['pid'] == $cat) { $result[ $v['id'] ] = $v; } } return $result; }; $parents = function ($cat) use (&$cats) { $result = []; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $result['cat_id' . $i] = 0; } do { $c = $cats[$cat]; $result['cat_id' . $c['numlevel']] = $c['id']; $cat = $c['pid']; } while (isset($cats[$cat])); unset($result['cat_id0']); return $result; }; foreach ($items as $v) { $cat = $v['cat_id']; if (! isset($cats[ $cat ])) { $this->errors->set('Incorrect cat id ' . $v['cat_id']); return 0; } if (empty($cats[ $cat ]['cnt'])) { continue; } $c = $cats[ $cat ]; do { $ch = $children($c['id']); if (empty($ch)) { $this->errors->set('Empty children ' . $cat); return 0; } foreach ($ch as $c) { if (empty($c['cnt'])) { break; } } } while (! empty($c['cnt'])); $p = $parents($c['id']); $p['cat_id'] = $c['id']; $this->db->update(static::TABLE_ITEMS, $p, ['cat_id' => $cat]); } return 0; } /** * Счетчики объявлений по регионам * @param array $filter * @param array $opts * @return array */ public function regionsItemsCounters(array $filter, array $opts = []) { if (empty($filter)) { return []; } $opts = $this->defaults($opts, [ 'cache' => 0, 'lang' => $this->locale->current(), ]); $catID = $filter['cat_id'] ?? 0; $filter['delivery'] = 0; $filter['no_region'] = 0; $filter[] = 'R.id = C.region_id'; $filter = $this->prepareFilter($filter); $regions = []; $countries = []; $data = $this->db->select(' SELECT R.id, R.keyword, R.title_' . $opts['lang'] . ' AS title, R.declension, R.numlevel, R.city, C.items FROM ' . Geo::TABLE_REGIONS . ' R, ' . static::TABLE_ITEMS_COUNTERS . ' C ' . $filter['where'] . ' ORDER BY R.numleft', $filter['bind'], $opts['cache']); foreach ($data as $v) { if ($v['numlevel'] > 1) { $regions[] = $v['id']; } } $regions = array_unique($regions); $regions = Geo::model()->regionsListing(['id' => $regions ?: 0], ['fields' => ['parents'], 'keyBy' => 'id']); foreach ($data as &$v) { $v['declension'] = Geo::prepareDeclension($v['declension']); $v['title.in'] = (!empty($v['declension']['where'][$opts['lang']]) ? $v['declension']['where'][$opts['lang']] : $v['title']); $v['url'] = [ 'city' => $v['city'] ? $v['keyword'] : '', 'region' . $v['numlevel'] => $v['keyword'], ]; $v['country_id'] = 0; if ($v['numlevel'] == 1) { $v['country_id'] = $v['id']; } elseif ($v['numlevel'] > 1) { $v['country_id'] = $regions[ $v['id'] ]['parents'][1]['id'] ?? 0; foreach ($regions[ $v['id'] ]['parents'] ?? [] as $kk => $vv) { $v['url']['region' . $kk] = $vv['keyword']; } } if ($v['country_id']) { $countries[] = $v['country_id']; } } unset($v); if (! empty($data)) { # добавим объявления с флагом "доставка в регионы" $countries = array_unique($countries); $delivery = $this->itemsCountByFilter(['cat_id' => $catID, 'region_id' => $countries, 'delivery' => 1], ['region_id', 'items'], false, $opts['cache']); if (! empty($delivery)) { $delivery = func::array_transparent($delivery, 'region_id', true); foreach ($data as &$v) { if (! isset($delivery[ $v['country_id'] ])) { continue; } $v['items'] += $delivery[ $v['country_id'] ]['items']; } unset($v); } } return $data; } /** * Счетчики объявлений по категориям * @param array $filter фильтр [region_id] * @param int $deliveryCountry включить с доставкой по стране * @return array данные о категориях с количеством объявлений в них */ public function catsItemsCounters(array $filter, int $deliveryCountry, array $opts = []) { $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); if (empty($filter)) { return []; } $region = 0; if (isset($filter['region_id'])) { $region = $filter['region_id']; unset($filter['region_id']); } $filter['enabled'] = 1; $filter[] = $this->db->langAnd(false, 'C', 'CL', $opts['lang']); $filter = $this->prepareFilter($filter, '', $region ? [':region' => $region] : []); $data = $this->db->select( 'SELECT C.id, C.pid, C.keyword, C.landing_url, CL.title, N.items FROM ' . static::TABLE_CATEGORIES . ' C LEFT JOIN ' . static::TABLE_ITEMS_COUNTERS . ' N ON C.id = N.cat_id AND N.delivery = 0 AND ' . ($region ? ' ((JSON_EXTRACT(C.settings, "$.geo.enabled") AND N.region_id = :region) OR (!JSON_EXTRACT(C.settings, "$.geo.enabled") AND N.no_region = 1)) ' : ' N.region_id = 0 AND N.no_region = 0') . ' , ' . static::TABLE_CATEGORIES_LANG . ' CL ' . $filter['where'] . ' ORDER BY C.numleft', $filter['bind'] ); # добавим счетчики объявлений с доставкой по всей стране if (! empty($data) && $deliveryCountry) { $cats = []; foreach ($data as $v) { $cats[] = $v['id']; } $counters = $this->itemsCountByFilter( ['cat_id' => $cats, 'region_id' => $deliveryCountry, 'delivery' => 1], ['cat_id', 'items'], false ); if (! empty($counters)) { $counters = func::array_transparent($counters, 'cat_id', true); foreach ($data as &$v) { if (! isset($counters[$v['id']])) { continue; } $v['items'] += $counters[$v['id']]['items']; } unset($v); } } foreach ($data as $k => $v) { if (! empty($v['items'])) { continue; } unset($data[$k]); } return $data; } /** * Получаем счетчики объявлений в указанных категориях * @param array $catsID ID категорий * @param mixed $geo фильтр региона [id, country] * @param array $opts доп. параметры * @return array счетчики объявлений сгруппированные по категории [ID категории=>Кол-во объявлений, ...] */ public function catsItemsCountersByID(array $catsID, $geo, array $opts = []) { $opts = $this->defaults($opts, [ 'cache' => 60, ]); $counters = []; # посчитаем количество объявлений в категориях $temp = $this->itemsCountByFilter([ 'cat_id' => $catsID, 'region_id' => $geo['id'], 'delivery' => 0, ], ['cat_id', 'items'], false, $opts['cache']); foreach ($temp as $v) { $counters[ $v['cat_id'] ] = $v['items']; } # доставки из регионов $countryID = $geo['parents'][1]['id'] ?? 0; if ($countryID) { $temp = $this->itemsCountByFilter([ 'cat_id' => $catsID, 'region_id' => $countryID, 'delivery' => 1, ], ['cat_id', 'items'], false, $opts['cache']); foreach ($temp as $v) { if (isset($counters[ $v['cat_id'] ])) { $counters[ $v['cat_id'] ] += $v['items']; } else { $counters[ $v['cat_id'] ] = $v['items']; } } } return $counters; } # ---------------------------------------------------------------- # Категории объявлений /** * Данные для формирования списка категорий * @param string $type тип списка категорий * @param int $parentID ID parent-категории * @param string $iconVariant размер иконки * @param array|bool $opts доп. параметры * @return mixed */ public function catsList($type, $parentID, $iconVariant, $opts = []) { if (is_bool($opts)) { $opts = ['ignoreVirtual' => $opts]; } $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), 'cache' => 60, 'ignoreVirtual' => false, # игнорировать виртуальные категории ]); $filter = [ 'C.pid != 0', 'C.enabled = 1', ]; if ($opts['ignoreVirtual']) { $filter[] = 'C.virtual_ptr IS NULL'; } $bind = []; $geo = Geo::filter(); $bind[':region'] = ! empty($geo['id']) ? $geo['id'] : 0; switch ($type) { case 'index': $filter[] = 'C.numlevel < 3'; break; case 'form': case 'search': if ($parentID > 0) { $filter[':pid'] = ['C.pid = :pid', ':pid' => $parentID]; } else { $filter[] = 'C.numlevel = 1'; } break; case 'filter': if (! empty($opts['filter'])) { $filter = array_merge($filter, $opts['filter']); } break; } $filter[] = $this->db->langAnd(false, 'C', 'CL', $opts['lang']); $filter = $this->prepareFilter($filter, '', $bind); $data = $this->db->select( 'SELECT C.id, C.pid, ' . ($iconVariant ? 'C.icon_' . $iconVariant . ' as i, ' : '') . ' CL.title as t, C.keyword as k, C.landing_url as lpu, IFNULL(IC.items, 0) AS items, (C.numright-C.numleft)>1 as subs, C.numlevel as lvl, C.settings FROM ' . static::TABLE_CATEGORIES . ' C LEFT JOIN ' . static::TABLE_ITEMS_COUNTERS . ' IC ON C.id = IC.cat_id ' . (! empty($geo['id']) ? ' AND ((JSON_EXTRACT(C.settings, "$.geo.enabled") AND IC.region_id = :region) OR (!JSON_EXTRACT(C.settings, "$.geo.enabled") AND IC.no_region = 1)) ' : ' AND IC.region_id = :region AND IC.no_region = 0') . ' AND IC.delivery = 0 INNER JOIN ' . static::TABLE_CATEGORIES . ' CP ON C.pid = CP.id AND CP.enabled = 1 , ' . static::TABLE_CATEGORIES_LANG . ' CL ' . $filter['where'] . ' ORDER BY C.numleft ASC', $filter['bind'], $opts['cache'] ); $this->catsSettingsMerge($data); # Строим счетчики объявлений в категориях $countryID = Geo::regionCountry($geo); if ($countryID > 0) { $cats = []; foreach ($data as $v) { $cats[] = $v['id']; } $counters = $this->itemsCountByFilter( ['cat_id' => $cats, 'region_id' => $countryID, 'delivery' => 1], ['cat_id', 'items'], false ); if (! empty($counters)) { $counters = func::array_transparent($counters, 'cat_id', true); foreach ($data as &$v) { if (! isset($counters[ $v['id'] ])) { continue; } $v['items'] += $counters[ $v['id'] ]['items']; } unset($v); } } return $data; } /** * Смежим настройки категории(settings) в данные категории * @param array $data @ref * @param array $opts */ public function catsSettingsMerge(&$data, array $opts = []) { if (empty($data)) { return; } $opts = array_merge([ 'name' => 'settings', 'list' => true, 'merge' => true, ], $opts); $name = $opts['name']; $merge = function (&$v) use (&$opts, &$name) { if (! isset($v[$name])) { return; } if (! empty($v[$name])) { $v[$name] = json_decode($v[$name], true); } if (! is_array($v[$name])) { $v[$name] = []; } if (! isset($v[$name]['price'])) { $v[$name]['price'] = []; } if (! isset($v[$name]['price']['enabled'])) { $v[$name]['price']['enabled'] = true; } if (! isset($v[$name]['price']['ranges'])) { $v[$name]['price']['ranges'] = []; } if (! isset($v[$name]['price']['ex'])) { $v[$name]['price']['ex'] = ItemPrice::EX_PRICE; } if ($opts['merge'] && ! empty($v[$name])) { $v = array_merge($v, $v[$name]); } }; if (! $opts['list']) { $merge($data); return; } foreach ($data as & $v) { $merge($v); } unset($v); } /** * Данные категорий для страницы /sitemap/ * @param string $iconVariant ключ размера иконки категории * @param array $opts * @return mixed */ public function catsListSitemap(string $iconVariant, array $opts = []) { $opts = $this->defaults($opts, [ 'cache' => 60, 'levelLimit' => 2, 'lang' => $this->locale->current(), ]); return $this->db->select( 'SELECT C.id, C.pid, C.icon_' . $iconVariant . ' as icon, CL.title, C.keyword, C.landing_url, IFNULL(IC.items, 0) AS items FROM ' . static::TABLE_CATEGORIES . ' C LEFT JOIN ' . static::TABLE_ITEMS_COUNTERS . ' IC ON C.id = IC.cat_id AND IC.region_id = 0 AND IC.delivery = 0 AND IC.no_region = 0 INNER JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE C.enabled = 1 AND C.pid != 0 AND C.numlevel <= ' . $opts['levelLimit'] . ' AND ' . $this->db->langAnd(false, 'C', 'CL', $opts['lang']) . ' ORDER BY C.numleft ASC', null, $opts['cache'] ); } /** * Данные категорий для формирования списка категорий в админ-панели * @param array $filter * @param array $fields * @return mixed */ public function catsListing(array $filter, array $fields = []) { $filter[':lang'] = $this->db->langAnd(false, 'C', 'CL'); $filter = $this->prepareFilter($filter, 'C'); $data = $this->db->tag('listings-cats-listing-data', ['filter' => &$filter, 'fields' => &$fields]) ->select( 'SELECT C.id, C.pid, C.enabled, C.numlevel, IF(C.numright-C.numleft>1,1,0) as node, CL.title, IFNULL(IC.items, 0) AS items, C.numleft, C.virtual_ptr, VC.title as virtual_name, C.settings ' . ( ! empty($fields) ? ', ' . join(',', $fields) : '') . ' FROM ' . static::TABLE_CATEGORIES . ' C LEFT JOIN ' . static::TABLE_ITEMS_COUNTERS . ' IC ON C.id = IC.cat_id AND IC.region_id = 0 AND IC.no_region = 0 LEFT JOIN ' . static::TABLE_CATEGORIES . ' VC ON C.virtual_ptr = VC.id , ' . static::TABLE_CATEGORIES_LANG . ' CL ' . $filter['where'] . ' ORDER BY C.numleft ASC', $filter['bind'] ); $this->catsSettingsMerge($data); return $data; } /** * Языковые данные категорий для формирования списка в API * @param array $filter * @param array $fields * @return mixed */ public function catsListingLang(array $filter, array $fields = ['id', 'lang', 'title']) { $filter = $this->prepareFilter($filter, 'C'); return $this->db->tag('listings-cats-listing-lang-data', ['filter' => &$filter]) ->select( 'SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_CATEGORIES_LANG . ' C ' . $filter['where'] . ' ORDER BY C.id ASC, C.lang', $filter['bind'] ); } /** * Отвязываем объявления от виртуальной категории * @param int $categoryID ID виртуальной категории * @return bool */ public function catVirtualDropItemsLink(int $categoryID) { # TODO: index return $this->db->update(static::TABLE_ITEMS, ['cat_id_virtual' => null], ['cat_id_virtual' => $categoryID]); } /** * Получаем ID/Keyword реальной категории * @param int|string $id ID/Keyword виртуальной категории (или реальной) * @param bool $searchByKeyword выполнять поиск по полю 'keyword' * @return string */ public function catToReal($id, bool $searchByKeyword = false) { $field = ($searchByKeyword ? 'keyword' : 'id'); $realID = $this->db->one_data( 'SELECT C.' . $field . ' FROM ' . static::TABLE_CATEGORIES . ' VC INNER JOIN ' . static::TABLE_CATEGORIES . ' C ON C.id = VC.virtual_ptr WHERE VC.' . $field . ' = :' . $field, [':' . $field => $id] ); return ( ! empty($realID) ? $realID : $id); } /** * Фильтр поиска по категории по полю cat_path * @param int|array $categoryID ID категории или нескольких категорий (вложенность от верхнего уровня) * @param bool $force строить фильтр и в случае если категория не указана * @return array|bool */ public function catPathFilter($categoryID, bool $force = false) { if (is_array($categoryID)) { if (! empty($categoryID)) { return ['cat_path LIKE :catQuery', ':catQuery' => '-' . join('-', $categoryID) . '-%']; } } elseif ($categoryID > 0) { $data = $this->catData($categoryID, ['id','pid','numlevel','numleft','numright']); if (! empty($data)) { if ($data['numlevel'] == 1) { return ['cat_path LIKE :catQuery', ':catQuery' => '-' . $data['id'] . '-%']; } elseif ($data['numlevel'] == 2) { return ['cat_path LIKE :catQuery', ':catQuery' => '-' . $data['pid'] . '-' . $data['id'] . '-%']; } elseif ($data['numlevel'] > 2) { $categoryParents = $this->catParentsID($data, true); return ['cat_path LIKE :catQuery', ':catQuery' => '-' . join('-', $categoryParents) . '-%']; } } } if ($force) { return ['cat_path LIKE :catQueryAny', ':catQueryAny' => '-%']; } return false; } /** * Данные о категории * @param int $categoryID * @param array|string $fields * @param bool $edit для редактирования в админ-панели * @return array|mixed */ public function catData(int $categoryID, $fields = [], bool $edit = false) { if (empty($categoryID)) { return []; } return $this->catDataByFilter(['id' => $categoryID], $fields, $edit); } /** * Языковые данные категории * @param int $categoryID ID категории * @param array $fields требуемые поля * @param array $lang список языков (по умолчанию - все) * @return array|mixed */ public function catDataLang(int $categoryID, array $fields = [], array $lang = []) { if (! $categoryID || empty($fields)) { return []; } if (empty($lang)) { $lang = $this->locale->getLanguages(); } if (! in_array('lang', $fields)) { $fields[] = 'lang'; } return $this->db->select_key('SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_CATEGORIES_LANG . ' WHERE id = :id AND ' . $this->prepareIN('lang', $lang, false, false, false), 'lang', [':id' => $categoryID]); } /** * Данные о категории на основе фильтра * @param array $filter * @param array|string $fields * @param bool $edit для редактирования в админ-панели * @return array|mixed */ public function catDataByFilter(array $filter, $fields = [], bool $edit = false) { if (isset($filter['lang'])) { $lang = $filter['lang']; unset($filter['lang']); } else { $lang = $this->locale->current(); } if (empty($fields) || $edit) { $fields = '*'; } $params = []; $bind = []; if ($fields == '*') { $params = [$fields]; $params[] = '((C.numright-C.numleft)>1) as subs'; } else { if (! is_array($fields)) { $fields = [$fields]; } foreach ($fields as $v) { if (isset($this->langCategories[$v])) { $v = 'CL.' . $v; } elseif ($v == 'subs') { $v = '((C.numright-C.numleft)>1) as subs'; } else { $v = 'C.' . $v; } $params[] = $v; } } $counters = false; if ($k = array_search('C.items', $params)) { $counters = true; $geo = Geo::filter(); $bind[':region'] = ! empty($geo['id']) ? $geo['id'] : 0; $params[$k] = 'IFNULL(IC.items, 0) AS items'; } $filter[':lng'] = $this->db->langAnd(false, 'C', 'CL', $lang); $filter = $this->prepareFilter($filter, 'C', $bind); $filter['where'] = str_replace('C.lang = :lang', 'CL.lang = :lang AND C.id = CL.id', $filter['where']); $data = $this->db->one_array( 'SELECT ' . join(',', $params) . ' FROM ' . static::TABLE_CATEGORIES . ' C ' . ($counters ? ' LEFT JOIN ' . static::TABLE_ITEMS_COUNTERS . ' IC ON C.id = IC.cat_id AND IC.region_id = :region AND IC.delivery = 0 AND IC.no_region = 0' : '') . ' , ' . static::TABLE_CATEGORIES_LANG . ' CL ' . $filter['where'] . ' LIMIT 1', $filter['bind'], ($edit ? 0 : 60) ); $this->catsSettingsMerge($data, ['list' => false]); if ($counters && ! empty($geo['id']) && ! empty($data['id'])) { $data['items'] += $this->itemsCountByFilter([ 'cat_id' => $data['id'], 'region_id' => Geo::regionCountry($geo), 'delivery' => 1, 'no_region' => 0, ]); $data['items'] += $this->itemsCountByFilter([ 'cat_id' => $data['id'], 'region_id' => 0, 'delivery' => 0, 'no_region' => 1, ]); } if ($edit) { $data['node'] = ($data['numright'] - $data['numleft']); if (! $this->isPOST()) { $this->db->langSelect($data['id'], $data, $this->langCategories, static::TABLE_CATEGORIES_LANG); } } return $data; } /** * Получение списка категорий по фильтру * @param array $filter список фильтров * @param array $fields список полей которые нужно получить * @param array $opts * @return array|bool */ public function catsDataByFilter(array $filter, array $fields = [], array $opts = []) { $opts = $this->defaults($opts, [ 'cache' => 0, 'lang' => $this->locale->current(), 'order' => 'C.numleft', 'limit' => 0, 'offset' => 0, ]); $select = []; if (empty($fields)) { $fields = '*'; } if ($fields === '*') { $select = [$fields]; } else { if (! is_array($fields)) { $fields = [$fields]; } foreach ($fields as $v) { if (isset($this->langCategories[$v])) { $v = 'CL.' . $v; } elseif ($v == 'subs') { $v = '((C.numright-C.numleft)>1) as subs'; } else { $v = 'C.' . $v; } $select[] = $v; } } $having = []; if (isset($filter[':ft_q'])) { $q = $filter[':ft_q']; $select[] = $this->db->prepareFulltextQuery($q, 'CL.title') . 'AS score '; $having[] = 'score > 0'; $opts['order'] = 'score DESC, ' . $opts['order']; unset($filter[':ft_q']); } $filter[':lng'] = $this->db->langAnd(false, 'C', 'CL', $opts['lang']); $filter = $this->prepareFilter($filter, 'C'); $limit = ''; if (! empty($opts['limit'])) { $limit = $this->db->prepareLimit($opts['offset'], $opts['limit']); } $data = $this->db->select( 'SELECT ' . join(',', $select) . ' FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL ' . $filter['where'] . (! empty($having) ? ' HAVING ' . join(' AND ', $having) : '') . ' ORDER BY ' . $opts['order'] . ' ' . $limit, $filter['bind'], $opts['cache'] ); $this->catsSettingsMerge($data); return $data; } /** * Получаем данные о child-категориях всех уровней вложенности * @param int $numLeft левая граница * @param int $numRight правая граница * @param array $fields требуемые поля child-категорий * @param string|null $lang язык записей * @return array|mixed */ public function catChildsTree(int $numLeft, int $numRight, array $fields = [], $lang = null) { if (empty($fields)) { $fields[] = 'id'; } foreach ($fields as $k => $v) { if ($v == 'id' || array_key_exists($v, $this->langCategories)) { $fields[$k] = 'CL.' . $v; } } $lang = $lang ?? $this->locale->current(); return $this->db->select_key('SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_CATEGORIES . ' C LEFT JOIN ' . static::TABLE_CATEGORIES_LANG . ' CL USING (id) WHERE C.numleft > :left AND C.numright < :right AND CL.lang = :lang ORDER BY C.numleft ASC', 'id', [ ':left' => $numLeft, ':right' => $numRight, ':lang' => $lang, ]); } /** * Копирование настроек категории в подкатегории * @param int $categoryID ID категории * @param array $selected отмеченные свойства * @return bool */ public function catDataCopyToSubs(int $categoryID, array $selected = []) { # настройки $params = $this->app->filter('listings.admin.category.form.copy2subs.data', [ 'seek' => [ 'settings' => ['seek'], 'lang' => [ 'type_offer_form', 'type_offer_search', 'type_seek_form', 'type_seek_search', ], ], 'price' => [ 'settings' => ['price'], ], 'quantitative' => [ 'settings' => ['quantitative'], ], 'photos' => [ 'settings' => ['photos'], ], 'video' => [ 'settings' => ['video'], ], 'owner' => [ 'settings' => ['owner_business','owner_search'], 'lang' => [ 'owner_private_form', 'owner_private_search', 'owner_business_form', 'owner_business_search', ], ], 'geo' => [ 'settings' => ['geo'], ], 'list_type' => [ 'settings' => ['list_type'], ], 'publication' => [ 'settings' => ['publication'], ] ]); $settingsFields = []; $dataFields = []; $langFields = []; foreach ($selected as $key) { if (isset($params[$key])) { foreach (($params[$key]['settings'] ?? []) as $v) { $settingsFields[] = $v; } foreach (($params[$key]['data'] ?? []) as $v) { $dataFields[] = $v; } foreach (($params[$key]['lang'] ?? []) as $v) { $langFields[] = $v; } } } $parent = $this->db->one_array( 'SELECT numleft, numright, numlevel, settings ' . (!empty($dataFields) ? ', ' . join(',', $dataFields) : '') . ' FROM ' . static::TABLE_CATEGORIES . ' WHERE id = :id', [':id' => $categoryID] ); if (empty($parent)) { return false; } $this->catsSettingsMerge($parent, ['list' => false, 'merge' => false]); $settings = $parent['settings'] ?? []; # нет подкатегорий if (($parent['numright'] - $parent['numleft']) == 1) { return true; } # получаем ID подкатегорий $subsID = $this->db->select_one_column('SELECT id FROM ' . static::TABLE_CATEGORIES . ' WHERE numleft > :left AND numright < :right', [ ':left' => $parent['numleft'], ':right' => $parent['numright'], ]); $data = $parent; unset($data['numleft'], $data['numright'], $data['numlevel'], $data['settings']); if (!empty($data)) { # шаг 1: data fields $this->db->update(static::TABLE_CATEGORIES, $data, ['id' => $subsID]); } # шаг2: lang fields $langsList = $this->locale->getLanguages(); if (! empty($langFields)) { foreach ($langsList as $lang) { $data2 = $this->db->one_array( 'SELECT ' . join(', ', $langFields) . ' FROM ' . static::TABLE_CATEGORIES_LANG . ' WHERE id = :id AND lang = :lang', [':id' => $categoryID, ':lang' => $lang] ); $this->db->update( static::TABLE_CATEGORIES_LANG, $data2, ['id' => $subsID, 'lang' => $lang] ); } } # шаг3: settings fields if (! empty($settingsFields) && ! empty($settings)) { $last = 0; do { $data = $this->db->select('SELECT id, settings FROM ' . static::TABLE_CATEGORIES . ' WHERE numleft > :left AND numright < :right AND id > :last ORDER BY id LIMIT 50 ', [ ':left' => $parent['numleft'], ':right' => $parent['numright'], ':last' => $last, ]); $this->catsSettingsMerge($data, ['merge' => false]); if (empty($data)) { break; } foreach ($data as $v) { $last = $v['id']; $s = $v['settings']; foreach ($settingsFields as $f) { if (! isset($settings[$f])) { continue; } $s[$f] = $settings[$f]; } $this->catSave($v['id'], ['settings' => $s]); } } while (! empty($data)); } return true; } /** * Сохранение данных категории * @param int $id ID категории * @param array $data данные * @return bool|int */ public function catSave(int $id, array $data) { if ($id) { $lang = $this->locale->current(); unset($data['pid']); # запрещаем изменение parent'a $data['modified'] = $this->now(); if (isset($data['settings']) && is_array($data['settings'])) { $data['settings'] = json_encode($data['settings'], JSON_UNESCAPED_UNICODE); } $this->db->langUpdate($id, $data, $this->langCategories, static::TABLE_CATEGORIES_LANG); $dataNonLang = array_diff_key($data, $this->langCategories); if (isset($data['title'][$lang])) { $dataNonLang['title'] = $data['title'][$lang]; } return $this->db->update(static::TABLE_CATEGORIES, $dataNonLang, ['id' => $id]); } else { $id = $this->treeCategories()->insertNode($data['pid']); if (! $id) { return 0; } unset($data['pid']); $data['created'] = $this->now(); $this->catSave($id, $data); $this->catsStructureChanged(true); return $id; } } /** * Смена parent-категории * @param int $categoryID ID перемещаемой категории * @param int $newParentID ID новой parent-категории * @return bool */ public function catChangeParent(int $categoryID, int $newParentID) { # выполняем смену parent-категории + проверку на максимальный уровень вложенности $success = $this->treeCategories()->changeParent($categoryID, $newParentID, Listings::catsDepthLimit()); if ($success !== true) { if ($success === -1) { $this->errors->set('Не удалось изменить основную категорию, максимальный уровень вложенности - ' . Listings::catsDepthLimit()); } return false; } # помечаем дату последнего измнения структуры категорий $this->catsStructureChanged(true); # обновляем связи объявлений с категориями $where = []; $cats = []; for ($i = Listings::catsDepthLimit(); $i > 0; $i--) { $where[] = 'I.cat_id' . $i . ' = :cat'; $cats['cat_id' . $i] = 0; } $prepareUpdate = function ($item) use ($cats) { $cache = $this->cache[__FUNCTION__] ?? []; $catID = $item['cat_id']; if (!isset($cache[$catID])) { $update = $cats; $update['cat_id' . $item['numlevel']] = $catID; if ($item['numlevel'] == 2) { $update['cat_id1'] = $item['pid']; } elseif ($item['numlevel'] > 2) { $data = $this->db->select('SELECT id, numlevel FROM ' . static::TABLE_CATEGORIES . ' WHERE numleft <= :left AND numright > :right AND numlevel > 0 ORDER BY numleft', [ ':left' => $item['numleft'], ':right' => $item['numright'], ]); if (!empty($data)) { foreach ($data as $v) { $update['cat_id' . $v['numlevel']] = $v['id']; } } } $this->cache[__FUNCTION__][$catID] = $update; $cache = $this->cache[__FUNCTION__]; } return $cache[$catID]; }; $this->db->select_iterator('SELECT I.id, I.cat_id, C.pid, C.numlevel, C.numleft, C.numright FROM ' . static::TABLE_ITEMS . ' I, ' . static::TABLE_CATEGORIES . ' C WHERE (' . join(' OR ', $where) . ') AND I.cat_id = C.id ORDER BY I.id ', [':cat' => $categoryID], function ($item) use ($prepareUpdate) { $this->db->update(static::TABLE_ITEMS, $prepareUpdate($item), ['id' => $item['id']]); $this->itemsIndexesUpdate([$item['id']], 'cat_path'); }); return true; } /** * Удаление категорию * @param int $categoryID * @param bool $andSubcats включая вложенные категории * @return bool */ public function catDelete(int $categoryID, bool $andSubcats = false): bool { if (! $categoryID) { return false; } # проверяем наличие подкатегорий $data = $this->catData($categoryID, '*', true); if ($data['node'] > 1 && ! $andSubcats) { $this->errors->set('Невозможно удалить категорию с подкатегориями'); return false; } # проверяем наличие связанных с категорией if ($andSubcats) { # и вложенныеми категориями $cats = ' cat_id = :id '; for ($i = 1; $i <= Listings::catsDepthLimit(); $i++) { $cats .= ' OR cat_id' . $i . ' = :id '; } $itemsTotal = $this->itemsCount([ ':cats' => [$cats, ':id' => $categoryID], # TODO: index ]); } else { $itemsTotal = $this->itemsCount(['cat_id' => $categoryID]); # TODO: index } if (! empty($itemsTotal)) { $this->errors->set('Невозможно удалить категорию с объявлениями'); return false; } # удаляем иконку категории $icon = Listings::categoryIcon($categoryID); foreach ($icon->getVariants() as $k => $v) { $icon->setVariant($k); $icon->delete(false, $data[$k]); } if ($andSubcats) { $fields = array_keys($icon->getVariants()); $fields[] = 'id'; $fields[] = 'landing_id'; # удаляем иконки вложенных категорий $children = $this->catChildsTree($data['numleft'], $data['numright'], $fields); if (! empty($children)) { foreach ($children as $v) { $icon->setRecordID($v['id']); foreach ($icon->getVariants() as $k => $vv) { $icon->setVariant($k); $icon->delete(false, $v[$k]); } # удаляем посадочные страницы вложенных категорий if (! empty($v['landing_id'])) { SEO::model()->landingpageDelete($v['landing_id']); } } } } # удаляем категорию $deleteID = $this->treeCategories()->deleteNode($categoryID); if (empty($deleteID)) { $this->errors->set('Ошибка удаления категории'); return false; } # помечаем дату последнего измнения структуры категорий $this->catsStructureChanged(true); # удаляем посадочную страницу if (! empty($data['landing_id'])) { SEO::model()->landingpageDelete($data['landing_id']); } return true; } /** * Удаление всех категорий объявлений * @return bool */ public function catDeleteAll(): bool { # чистим таблицу категорий (+ зависимости по внешним ключам) $this->db->exec('DELETE FROM ' . static::TABLE_CATEGORIES . ' WHERE id > 0'); $this->db->exec('ALTER TABLE ' . static::TABLE_CATEGORIES . ' AUTO_INCREMENT = 2'); # чистим связанные посадочные страницы $this->db->exec('DELETE FROM ' . SEO::TABLE_LANDING_PAGES . ' WHERE joined > 0 AND joined_module = :module', [':module' => 'listings-cats']); # создаем корневую директорию $rootID = static::CATS_ROOTID; $rootTitle = 'Корневой раздел'; $data = [ 'id' => $rootID, 'pid' => 0, 'numleft' => 1, 'numright' => 2, 'numlevel' => 0, 'title' => $rootTitle, 'keyword' => 'root', 'enabled' => 1, 'created' => $this->now(), 'modified' => $this->now(), ]; $res = $this->db->insert(static::TABLE_CATEGORIES, $data); if (! empty($res)) { $dataLang = ['title' => []]; foreach ($this->locale->getLanguages() as $lng) { $dataLang['title'][$lng] = $rootTitle; } $this->db->langInsert($rootID, $dataLang, $this->langCategories, static::TABLE_CATEGORIES_LANG); } return !empty($res); } /** * Переключение свойств категории * @param int $categoryID ID категории * @param string $field название поля * @return bool|mixed */ public function catToggle(int $categoryID, string $field) { if ($categoryID <= 0) { return false; } switch ($field) { case 'addr_map': { return $this->toggleInt(static::TABLE_CATEGORIES, $categoryID, 'geo_addr', 'id'); } case 'enabled': { $res = $this->toggleInt(static::TABLE_CATEGORIES, $categoryID, 'enabled', 'id'); if ($res) { $catData = $this->catData($categoryID, ['numleft', 'numright', 'enabled', 'landing_id']); if (!empty($catData)) { $this->db->update( static::TABLE_CATEGORIES, ['enabled' => $catData['enabled']], ['numleft > :left AND numright < :right'], [ ':left' => $catData['numleft'], ':right' => $catData['numright'], ] ); if (!empty($catData['landing_id'])) { SEO::model()->landingpageToggle($catData['landing_id'], 'enabled'); } } } return $res; } } return false; } /** * Сортировка категорий объявлений в списке (tablednd) * @return array|bool */ public function catsRotate() { $res = $this->treeCategories()->rotateTablednd(); # помечаем дату последнего измнения структуры категорий if ($res !== false) { $this->catsStructureChanged(true); } return $res; } /** * Экспорт данных о категориях * @param string $type формат экспорта: txt * @param string|null $lang * @return array|mixed */ public function catsExport(string $type, ?string $lang = null) { $lang = $lang ?? $this->locale->current(); $data = []; if (empty($type) || $type === 'txt') { $data = $this->db->select('SELECT C.id, C.numlevel, ((C.numright-C.numleft)>1) as subs, CL.title FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE C.id != :rootID AND C.id = CL.id AND CL.lang = :lang ORDER BY C.numleft ', [':rootID' => static::CATS_ROOTID, ':lang' => $lang]); } if (empty($data)) { $data = []; } return $data; } /** * Получаем данные о parent-категориях * @param int|array $categoryData ID категории или данные о ней [id,numleft,numright] * @param array $fields требуемые поля parent-категорий * @param array $opts * @return array */ public function catParentsData($categoryData, array $fields = ['id', 'title', 'keyword'], array $opts = []): array { $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), 'includingSelf' => true, # включая категорию $categoryData 'exludeRoot' => true, # исключая данные о корневом элементе ]); if (empty($fields)) { $fields[] = 'id'; } foreach ($fields as $k => $v) { if ($v == 'id' || array_key_exists($v, $this->langCategories)) { $fields[$k] = 'CL.' . $v; } if ($v == 'subs') { $fields[$k] = '((C.numright-C.numleft)>1) as subs'; } } if (is_array($categoryData)) { if (empty($categoryData)) { return []; } # check required category data foreach (['id','numleft','numright'] as $k) { if (! isset($categoryData[$k])) { return []; } } $parentsData = $this->db->select( 'SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE ((C.numleft <= ' . $categoryData['numleft'] . ' AND C.numright > ' . $categoryData['numright'] . ')' . ($opts['includingSelf'] ? ' OR C.id = ' . $categoryData['id'] : '') . ') ' . ($opts['exludeRoot'] ? ' AND C.id != ' . static::CATS_ROOTID : '') . ' ' . $this->db->langAnd(true, 'C', 'CL', $opts['lang']) . ' ORDER BY C.numleft' ); } else { if ($categoryData <= 0) { return []; } $parentsID = $this->treeCategories()->getNodeParentsID( $categoryData, ($opts['exludeRoot'] ? ' AND id != ' . static::CATS_ROOTID : ''), $opts['includingSelf'] ); if (empty($parentsID)) { return []; } $parentsData = $this->db->select('SELECT ' . join(',', $fields) . ' FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE C.id IN(' . join(',', $parentsID) . ') ' . $this->db->langAnd(true, 'C', 'CL', $opts['lang']) . ' ORDER BY C.numleft '); } $this->catsSettingsMerge($parentsData); return func::array_transparent($parentsData, 'id', true); } /** * Получаем данные о parent-категориях * @param array|int $categoryData ID категории или данные о текущей категории: id, pid, numlevel, numleft, numright, ... * @param bool $includingSelf включать текущую в итоговых список * @param bool $exludeRoot исключить корневую категорию * @return array [lvl=>id, ...] */ public function catParentsID($categoryData, bool $includingSelf = true, bool $exludeRoot = true): array { if (! is_array($categoryData)) { $categoryData = $this->catDataByFilter(['id' => $categoryData], [ 'id','pid','numlevel','numleft','numright', ]); if (empty($categoryData)) { return []; } } $parentsID = []; if (! $exludeRoot) { $parentsID[0] = 1; } if ($categoryData['numlevel'] == 1) { if ($includingSelf) { $parentsID[1] = $categoryData['id']; } } elseif ($categoryData['numlevel'] == 2) { $parentsID[1] = $categoryData['pid']; if ($includingSelf) { $parentsID[2] = $categoryData['id']; } } else { $data = $this->db->select( 'SELECT id, numlevel FROM ' . static::TABLE_CATEGORIES . ' WHERE numleft <= ' . $categoryData['numleft'] . ' AND numright > ' . $categoryData['numright'] . ($exludeRoot ? ' AND numlevel > 0' : '') . ' ORDER BY numleft' ); $parentsID = []; if (! empty($data)) { foreach ($data as $v) { $parentsID[$v['numlevel']] = $v['id']; } } if ($includingSelf) { $parentsID[] = $categoryData['id']; } } return $parentsID; } /** * Формирование списка подкатегорий * @param int $categoryID ID категории * @param bool|array $asOptions формировать select-options (@see HTML::selectOptions) или FALSE * @param array $opts * @return array|string */ public function catSubcatsData(int $categoryID, $asOptions = false, array $opts = []) { $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); $data = $this->db->select( 'SELECT C.id, CL.title FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE C.pid = :pid ' . $this->db->langAnd(true, 'C', 'CL', $opts['lang']) . ' ORDER BY C.numleft', [':pid' => $categoryID] ); if (empty($asOptions)) { return $data; } else { return HTML::selectOptions($data, $asOptions['sel'], $asOptions['empty'], 'id', 'title'); } } /** * Обработка редактирования keyword'a в категории с подменой его в путях подкатегорий * @param int $categoryID ID категории * @param string $keywordPrev предыдущий keyword * @return bool */ public function catSubcatsRebuildKeyword(int $categoryID, string $keywordPrev) { $catData = $this->catData($categoryID, ['pid','keyword','numleft','numright','numlevel']); if (empty($catData)) { return false; } if ($catData['pid'] == static::CATS_ROOTID) { $from = $keywordPrev . '/'; } else { $parentData = $this->catData($catData['pid'], ['keyword']); if (empty($parentData)) { return false; } $from = $parentData['keyword'] . '/' . $keywordPrev . '/'; } # перестраиваем полный путь подкатегорий $catsUpdated = $this->db->update( static::TABLE_CATEGORIES, ['keyword = REPLACE(keyword, :from, :to)'], 'numleft > :left AND numright < :right', [ ':from' => $from, ':to' => $catData['keyword'] . '/', ':left' => $catData['numleft'], ':right' => $catData['numright'], ] ); if (! empty($catsUpdated)) { # перестраиваем ссылки в объявлениях $prefix = '/search/'; $this->db->update( static::TABLE_ITEMS, ['link = REPLACE(link, :from, :to)'], 'cat_id' . $catData['numlevel'] . ' = :cat', [ ':from' => $prefix . $from, ':to' => $prefix . $catData['keyword'] . '/', ':cat' => $categoryID, ] ); return true; } return false; } /** * Автоматическое создание посадочных страниц без /search/ для категорий * @param bool $refresh обновить полностью * @return int кол-во затронутых категорий */ public function catsLandingPagesAuto(bool $refresh = false) { $updated = 0; if ($refresh) { $this->db->exec('DELETE FROM ' . SEO::TABLE_LANDING_PAGES . ' WHERE joined > 0 AND joined_module = :module', [':module' => 'listings-cats']); $this->db->exec('DELETE FROM ' . SEO::TABLE_REDIRECTS . ' WHERE joined > 0 AND joined_module = :module', [':module' => 'listings-cats']); $this->db->exec('UPDATE ' . static::TABLE_CATEGORIES . ' SET landing_id = 0, landing_url = :empty WHERE landing_id > 0', [':empty' => '']); } $filter = [':root' => 'numlevel > 0']; if (!$refresh) { $filter['landing_id'] = 0; } $filter = $this->prepareFilter($filter); $this->db->select_iterator( 'SELECT id, keyword FROM ' . static::TABLE_CATEGORIES . $filter['where'], (!empty($filter['bind']) ? $filter['bind'] : []), function ($cat) use (&$updated) { $this->request->set('landing_url', '/' . $cat['keyword'] . '/'); $originalURL = Listings::url('items.search', ['keyword' => $cat['keyword'], 'region' => false], true); $originalURL = str_replace('//{sitehost}', '', $originalURL); # Посадочная страница $landingData = SEO::joinedLandingpage($this->controller, 'search-category', $originalURL, [ 'joined-id' => $cat['id'], 'joined-module' => 'listings-cats', ]); if (!empty($landingData['id'])) { $this->catSave($cat['id'], [ 'landing_id' => $landingData['id'], 'landing_url' => $landingData['url'], ]); # Редирект SEO::model()->redirectSave(0, [ 'from_uri' => $originalURL, 'to_uri' => $landingData['url'], 'status' => 301, 'is_relative' => 1, 'add_extra' => 1, 'add_query' => 1, 'enabled' => 1, 'joined' => $cat['id'], 'joined_module' => 'listings-cats', ]); $updated++; } } ); return $updated; } /** * Является ли категория основной * @param int $categoryID ID категории * @param int $parentID ID parent-категории (для избежания запроса к БД) * @return bool true - основная, false - подкатегория */ public function catIsMain(int $categoryID, int $parentID = 0): bool { if (! empty($parentID)) { return ($parentID == static::CATS_ROOTID); } else { $numLevel = $this->treeCategories()->getNodeNumlevel($categoryID); return ($numLevel == 1); } } /** * Формирование выпадающего списка категорий * @param string $type тип требуемого списка * @param int $selectedID ID выбранной категории * @param string|bool $emptyOption параметры значения по умолчанию * @param array $extra доп. настройки * @param array $opts доп. параметры * @return string|array select::options */ public function catsOptions(string $type, int $selectedID = 0, $emptyOption = false, array $extra = [], array $opts = []) { $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), 'array' => false, 'limit' => 0, ]); $sqlWhere = [$this->db->langAnd(false, 'C', 'CL', $opts['lang'])]; $sqlBind = []; $selectFields = [ 'C.id', 'C.pid', 'CL.title', 'C.numlevel', 'C.numleft', 'C.numright', '0 as disabled', 'C.settings', ]; $countItems = false; switch ($type) { case 'adm-item-form': $sqlWhere[] = '(C.numright-C.numleft) = 1'; break; case 'adm-items-listing': $sqlWhere[] = 'C.numlevel > 0'; break; case 'adm-companies-listing': $sqlWhere[] = '(C.numlevel = 1 ' . ($selectedID > 0 ? ' OR C.id = ' . $selectedID : '') . ')'; break; case 'adm-category-form-add': $sqlWhere[] = 'C.numlevel < ' . Listings::catsDepthLimit(); $countItems = true; break; case 'adm-category-form-edit': $sqlWhere[] = '( ! (C.numleft > ' . $extra['numleft'] . ' AND C.numright < ' . $extra['numright'] . ') AND C.id != ' . $extra['id'] . ')'; unset($extra['id']); break; case 'adm-svc-prices-ex': $sqlWhere[] = 'C.numlevel IN(1,2)'; break; case 'adm-category-add-virtual': $sqlWhere[] = 'C.numlevel <= ' . Listings::catsDepthLimit(); $sqlWhere[] = 'C.numlevel > ' . 0; $sqlWhere[] = 'C.virtual_ptr IS NULL'; $selectFields[] = '((C.numright-C.numleft)>1) as subs'; break; } if (! empty($extra['numlevel'])) { $sqlWhere[] = 'C.numlevel = :lvl'; $sqlBind[':lvl'] = $extra['numlevel']; } if (! empty($extra['q'])) { $sqlWhere[] = 'CL.title LIKE :q'; $sqlBind[':q'] = '%' . $extra['q'] . '%'; } if (! empty($extra['id'])) { $sqlWhere[] = 'C.id = :id'; $sqlBind[':id'] = $extra['id']; } $data = $this->db->select( 'SELECT ' . join(',', $selectFields) . ' ' . ($countItems ? ', SUM(I.items) as items ' : ', C.items') . ' FROM ' . static::TABLE_CATEGORIES_LANG . ' CL, ' . static::TABLE_CATEGORIES . ' C ' . ($countItems ? ' LEFT JOIN ' . static::TABLE_ITEMS_COUNTERS . ' I ON C.id = I.cat_id AND I.region_id = 0 AND I.no_region = 0 AND (C.numright-C.numleft) = 1' : '') . ' WHERE ' . join(' AND ', $sqlWhere) . ' GROUP BY C.id ORDER BY C.enabled DESC, C.numleft ' . ($opts['limit'] ? ' LIMIT ' . $opts['limit'] : ''), $sqlBind ); if (empty($data)) { $data = []; } $this->catsSettingsMerge($data, ['merge' => false]); if ($type === 'adm-category-form-add') { foreach ($data as &$v) { $v['disabled'] = ($v['numlevel'] > 0 && $v['items'] > 0); } unset($v); } elseif ($type === 'adm-category-add-virtual') { foreach ($data as &$v) { $v['disabled'] = $v['subs'] > 0; } unset($v); } if ($opts['array']) { return $data; } return Listings::template('admin/categories.options', [ 'categories' => $data, 'emptyOption' => $emptyOption, 'selectedID' => $selectedID, ]); } /** * Формирование списков категорий (при добавлении/редактировании объявления в админ панели, при поиске в категории) * @param array $categoriesID ID категории [lvl=>selectedCatID, ...] * @param mixed $asOptions формировать select-options или возвращать массивы данных о категориях * @param array|bool $opts * @return array [lvl=>[a=>id выбранной,cats=>список категорий(массив или options)],...] */ public function catsOptionsByLevel(array $categoriesID, $asOptions = false, $opts = []) { if (! is_array($opts)) { $opts = ['url_keywords' => !empty($opts)]; } $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), 'url_keywords' => false, # подготовить keyword'ы для построения ссылок ]); if (empty($categoriesID)) { $categoriesID = [1 => 0]; } # формируем список уровней для которых необходимо получить категории $levels = []; $fill = true; $parentID = static::CATS_ROOTID; foreach ($categoriesID as $lvl => $catID) { if ($catID || $fill) { $levels[$lvl] = $parentID; if (!$catID) { break; } $parentID = $catID; } else { break; } } if (empty($levels)) { return []; } $data = $this->db->select('SELECT C.id, CL.title as t, CL.breadcrumb as cr, C.keyword as k, C.numlevel as lvl FROM ' . static::TABLE_CATEGORIES . ' C, ' . static::TABLE_CATEGORIES_LANG . ' CL WHERE C.numlevel IN (' . join(',', array_keys($levels)) . ') AND C.pid IN(' . join(',', $levels) . ') ' . $this->db->langAnd(true, 'C', 'CL', $opts['lang']) . ' ORDER BY C.numleft'); if (empty($data)) { return []; } $levels = []; foreach ($data as $v) { $levels[$v['lvl']][$v['id']] = $v; } unset($data); if (! empty($asOptions)) { foreach ($categoriesID as $lvl => $selectedID) { if (isset($levels[$lvl])) { $categoriesID[$lvl] = [ 'a' => $selectedID, 'cats' => HTML::selectOptions($levels[$lvl], $selectedID, $asOptions['empty'], 'id', 't'), ]; } else { $categoriesID[$lvl] = [ 'a' => $selectedID, 'cats' => false, ]; } } } elseif ($opts['url_keywords']) { foreach ($categoriesID as $lvl => $selectedID) { $categoriesID[$lvl] = [ 'a' => $selectedID, 'cats' => (isset($levels[$lvl]) ? $levels[$lvl] : false), ]; } } return $categoriesID; } /** * Формируем запрос по фильтру "цена" * @param int $priceFrom от * @param int $priceTo до * @param int $priceCurrency валюта * @param array $priceRanges диапазоны * @param array $categoryData данные категории * @param string $tablePrefix префикс таблицы, формат: 'T.' * @return string SQL условие */ public function preparePriceQuery($priceFrom, $priceTo, $priceCurrency, array $priceRanges, array $categoryData, string $tablePrefix = '') { if (empty($categoryData['settings']['price'])) { return ''; } $price = $categoryData['settings']['price']; $priceCurrencyID = Currency::data($price['curr'], 'id'); $priceField = $tablePrefix . 'price_search'; $sql = []; # от - до if (! empty($priceFrom) || !empty($priceTo)) { $priceFromTo = $priceCurrency ?: $priceCurrencyID; $from = Currency::convertPriceToDefault($priceFrom, $priceFromTo); $to = Currency::convertPriceToDefault($priceTo, $priceFromTo); if ($from > 0 && $to > 0 && $from >= $to) { $from = 0; } $sql[] = '(' . ($from > 0 ? "$priceField >= " . $from . ($to > 0 ? " AND $priceField <= " . $to : '') : "$priceField <= " . $to) . ')'; } # диапазоны if (! empty($price['ranges']) && ! empty($priceRanges)) { foreach ($priceRanges as $v) { if (isset($price['ranges'][$v])) { $v = $price['ranges'][$v]; $v['from'] = Currency::convertPriceToDefault($v['from'], $priceCurrencyID); $v['to'] = Currency::convertPriceToDefault($v['to'], $priceCurrencyID); $sql[] = '(' . ($v['from'] ? "$priceField >= " . $v['from'] . ($v['to'] ? " AND $priceField <= " . $v['to'] : '') : "$priceField <= " . $v['to']) . ')'; } } } return (!empty($sql) ? '(' . join(' OR ', $sql) . ')' : ''); } /** * Поиск категории по названию для автокомплитера * @param string $searchQuery * @param array $filter * @param array|int $opts * @return array|mixed */ public function catsAutocompleter(string $searchQuery, array $filter = [], $opts = []) { if (is_numeric($opts)) { $opts = ['limit' => $opts]; } $opts = $this->defaults($opts, [ 'limit' => 15, 'lang' => $this->locale->current(), ]); if (empty($searchQuery)) { return []; } $filter[] = $this->db->langAnd(false, 'C', 'CL', $opts['lang']); $filter[':j'] = 'C.id = Q.id'; $filter[':q'] = ['Q.title LIKE :q ', ':q' => '%' . $searchQuery . '%']; $filter = $this->prepareFilter($filter); return $this->db->select( 'SELECT C.id, C.pid, C.pid, C.numlevel, C.numleft, C.numright, CL.title FROM ' . static::TABLE_CATEGORIES . ' C , ' . static::TABLE_CATEGORIES_LANG . ' CL , ' . static::TABLE_CATEGORIES_LANG . ' Q ' . $filter['where'] . ' GROUP BY id' . $this->db->prepareLimit(0, $opts['limit']), $filter['bind'] ); } /** * Получить всех парентов для списка категорий * @param array $data * @return array */ public function catsParents($data) { if (empty($data)) { return []; } # достанем всех парентов для каждого элемента выборки $parents = []; $need = $data; do { $p = []; foreach ($need as $v) { if (isset($parents[ $v['pid'] ])) { continue; } $p[ $v['pid'] ] = 1; } $need = $this->catsListing([ 'id' => array_keys($p), 'numlevel' => ['>', 0], ]); foreach ($need as $v) { $parents[ $v['id'] ] = $v; } } while (! empty($need)); $result = $data; foreach ($result as & $v) { $r = []; $p = $v['pid']; while (isset($parents[$p])) { $pa = $parents[$p]; $r[ $pa['numlevel'] ] = $pa; $p = $pa['pid']; } $v['parents'] = array_reverse($r, true); } unset($v); return $result; } /** * Дата последнего изменения структуры категорий объявлений * @param bool $update true - обновить до текущей даты, false - вернуть дату последнего изменения * @return mixed|string */ public function catsStructureChanged(bool $update = false) { if ($update) { $now = $this->now(); $this->db->update(static::TABLE_CATEGORIES, [ 'modified' => $now, ], [ 'id' => static::CATS_ROOTID, ]); return $now; } else { return $this->db->one_data('SELECT modified FROM ' . static::TABLE_CATEGORIES . ' WHERE id = :id', [ ':id' => static::CATS_ROOTID, ]); } } /** * Формирование SQL select конструкции для извлечения cat_id_lvl из cat_path (для выборки из индекса) * @param int $level уровень категории * @param string $field название поля * @return string */ public function catPathExtract(int $level, string $field = 'cat_path'): string { # -2-1306-18-346- if ($level == 1) { return 'CAST(SUBSTRING(SUBSTRING_INDEX(' . $field . ', \'-\', 2), 2) AS UNSIGNED)'; } else { $level++; return 'CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(' . $field . ', \'-\', ' . $level . '), \'-\', -1) AS UNSIGNED)'; } } # ---------------------------------------------------------------- # Типы категорий объявлений /** * Список типов категорий * @param array $filter * @param string|null $lang * @return mixed */ public function cattypesListing(array $filter, ?string $lang = null) { if (empty($filter)) { $filter = []; } $lang = $lang ?? $this->locale->current(); $filter[] = 'T.cat_id = C.id'; $filter = $this->prepareFilter($filter); return $this->db->select( 'SELECT T.*, T.title_' . $lang . ' as title, C.title as cat_title FROM ' . static::TABLE_CATEGORIES_TYPES . ' T, ' . static::TABLE_CATEGORIES . ' C ' . $filter['where'] . ' ORDER BY C.numleft, T.num ASC', $filter['bind'] ); } /** * Данные о типе категории * @param int $typeID * @param array $fields * @param bool $edit * @return array */ public function cattypeData(int $typeID, array $fields = [], bool $edit = false) { if (empty($fields)) { $fields = ['*']; } $data = $this->db->select_row(static::TABLE_CATEGORIES_TYPES, $fields, ['id' => $typeID]); if (empty($data)) { return []; } if ($edit) { $this->db->langFieldsSelect($data, $this->langCategoriesTypes); } return $data; } /** * Сохранение данных о типе категории (создание типа) * @param int $typeID * @param int $categoryID * @param array $data * @return bool|int|mixed */ public function cattypeSave(int $typeID, int $categoryID, array $data) { $now = $this->now(); if ($typeID) { $data['modified'] = $now; $this->db->langFieldsModify($data, $this->langCategoriesTypes, $data); return $this->db->update(static::TABLE_CATEGORIES_TYPES, $data, ['id' => $typeID]); } else { $maxNum = (int)$this->db->one_data('SELECT MAX(num) FROM ' . static::TABLE_CATEGORIES_TYPES . ' WHERE cat_id = ' . $categoryID); $data['num'] = $maxNum + 1; $data['cat_id'] = $categoryID; $data['created'] = $now; $data['modified'] = $now; $this->db->langFieldsModify($data, $this->langCategoriesTypes, $data); return $this->db->insert(static::TABLE_CATEGORIES_TYPES, $data); } } /** * Удаление типа категории * @param int $typeID * @return bool */ public function cattypeDelete(int $typeID) { if (! $typeID || !static::CATS_TYPES_EX) { return false; } # удаляем только "свободный" тип # TODO: index(cat_type) $itemsIn = $this->db->one_data('SELECT COUNT(id) FROM ' . static::TABLE_ITEMS . ' WHERE cat_type = :id', [':id' => $typeID]); if (! empty($itemsIn)) { $this->errors->set(_t('listings', 'Unable to delete category type with listings')); return false; } $res = $this->db->delete(static::TABLE_CATEGORIES_TYPES, ['id' => $typeID]); return !empty($res); } /** * Переключение параметра типа категории * @param int $typeID ID типа * @param string $field название поля * @return bool|mixed */ public function cattypeToggle(int $typeID, string $field) { if (! $typeID) { return false; } switch ($field) { case 'enabled': { return $this->toggleInt(static::TABLE_CATEGORIES_TYPES, $typeID, 'enabled', 'id'); } break; } return false; } /** * Сортировка типов в списке (tablednd) * @param int $categoryID ID категории * @return array|bool */ public function cattypesRotate(int $categoryID) { return $this->db->rotateTablednd(static::TABLE_CATEGORIES_TYPES, ' AND cat_id = ' . $categoryID); } /** * Формирование списка типов, привязанных к категории * @param int $categoryID ID категории * @param bool|array $htmlOptions формировать select-options или FALSE * @param array $opts * @return array|string */ public function cattypesByCategory(int $categoryID, $htmlOptions = false, array $opts = []) { $data = []; $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); do { if (empty($categoryID)) { break; } $categoryParentsID = $this->catParentsID($categoryID); if (empty($categoryParentsID)) { break; } $data = $this->db->select_key( 'SELECT T.id, T.title_' . $opts['lang'] . ' as title, T.items FROM ' . static::TABLE_CATEGORIES_TYPES . ' T, ' . static::TABLE_CATEGORIES . ' C WHERE T.cat_id IN (' . join(',', $categoryParentsID) . ') AND T.cat_id = C.id ORDER BY C.numleft, T.num ASC', 'id' ); } while (false); if (! empty($htmlOptions)) { return HTML::selectOptions($data, $htmlOptions['sel'], $htmlOptions['empty'], 'id', 'title'); } else { return $data; } } /** * Формирование списка простых типов (static::TYPE_) * @param array $categoryData данные о категории * @param bool $isSearch для поиска * @param array $opts * @return array */ public function cattypesSimple(array $categoryData, bool $isSearch, array $opts = []) { if (empty($categoryData) || (!isset($categoryData['seek']) && !isset($categoryData['settings']['seek']))) { return []; } $seek = $categoryData['settings']['seek'] ?? $categoryData['seek']; $opts = $this->defaults($opts, [ 'lang' => $this->locale->current(), ]); $types = [ static::TYPE_OFFER => ['id' => static::TYPE_OFFER, 'title' => ''], ]; if ($isSearch) { $types[static::TYPE_OFFER]['title'] = (!empty($categoryData['type_offer_search']) ? $categoryData['type_offer_search'] : _t('listings', 'Listings', [], $opts['lang'])); } else { $types[static::TYPE_OFFER]['title'] = (!empty($categoryData['type_offer_form']) ? $categoryData['type_offer_form'] : _t('listings', 'Suggest', [], $opts['lang'])); } if ($seek) { $types[static::TYPE_SEEK] = ['id' => static::TYPE_SEEK, 'title' => '']; if ($isSearch) { $types[static::TYPE_SEEK]['title'] = (!empty($categoryData['type_seek_search']) ? $categoryData['type_seek_search'] : _t('listings', 'Listings', [], $opts['lang'])); } else { $types[static::TYPE_SEEK]['title'] = (!empty($categoryData['type_seek_form']) ? $categoryData['type_seek_form'] : _t('listings', 'Looking for', [], $opts['lang'])); } } return $types; } # ---------------------------------------------------------------- # Жалобы /** * Список жалоб * @param array $filter * @param bool $countOnly * @param string $limit * @return int|mixed */ public function claimsListing(array $filter, bool $countOnly = false, string $limit = '') { if ($countOnly) { $filter = $this->prepareFilter($filter, 'CL'); return (int)$this->db->tag('listings-claims-listing-count', ['filter' => &$filter]) ->one_data('SELECT COUNT(CL.id) FROM ' . static::TABLE_ITEMS_CLAIMS . ' CL ' . $filter['where'], $filter['bind']); } $filter[':jitem'] = 'CL.item_id = I.id '; $filter = $this->prepareFilter($filter, 'CL'); return $this->db->tag('listings-claims-listing-data', ['filter' => &$filter])->select( 'SELECT CL.*, U.name, U.login, U.blocked as ublocked, U.deleted as udeleted, I.link FROM ' . static::TABLE_ITEMS_CLAIMS . ' CL LEFT JOIN ' . Users::TABLE_USERS . ' U ON CL.user_id = U.user_id, ' . static::TABLE_ITEMS . ' I ' . $filter['where'] . ' ORDER BY CL.created DESC' . $limit, $filter['bind'] ); } /** * Данные о жалобе * @param int $claimID * @param array|string $fields * @return array */ public function claimData(int $claimID, array $fields = []): array { if (empty($fields)) { $fields = ['*']; } $data = $this->db->select_row(static::TABLE_ITEMS_CLAIMS, $fields, ['id' => $claimID]); if (empty($data)) { return []; } return $data; } /** * Сохранение жалобы * @param int $claimID * @param array $data * @return bool|int|mixed */ public function claimSave(int $claimID, array $data) { if ($claimID) { return $this->db->update(static::TABLE_ITEMS_CLAIMS, $data, ['id' => $claimID]); } else { $data['created'] = $this->now(); $data['user_id'] = $data['user_id'] ?? User::id(); $data['user_ip'] = $data['user_ip'] ?? Request::remoteAddress(); $claimID = $this->db->insert(static::TABLE_ITEMS_CLAIMS, $data); if ($claimID > 0) { $data['id'] = $claimID; $this->app->hook('listings.item.claim.create', $data); } return $claimID; } } /** * Удаление жалобы * @param int $claimID * @return bool */ public function claimDelete(int $claimID) { if (! $claimID) { return false; } return $this->db->delete(static::TABLE_ITEMS_CLAIMS, ['id' => $claimID]); } /** * Список таблиц модели с мультиязычными данными * @return array */ public function getLocaleTables(): array { $data = [ static::TABLE_CATEGORIES => [ 'type' => 'table', 'fields' => $this->langCategories, 'title' => _t('listings', 'Categories'), 'translatable-data' => true, ], static::TABLE_CATEGORIES_TYPES => [ 'type' => 'fields', 'fields' => $this->langCategoriesTypes, 'title' => _t('listings', 'Category types'), 'translatable-data' => true, ], static::TABLE_CATEGORIES_DYNPROPS => [ 'type' => 'fields', 'fields' => [ 'title' => TYPE_NOTAGS, 'description' => TYPE_NOTAGS, ], 'title' => _t('listings', 'Dynamic Properties'), 'translatable-data' => true, ], static::TABLE_CATEGORIES_DYNPROPS_MULTI => [ 'type' => 'fields', 'fields' => ['name' => TYPE_NOTAGS], 'title' => _t('listings', 'Properties (value)'), 'id' => ['dynprop_id', 'value'], 'translatable-data' => true, ], ]; if (! static::CATS_TYPES_EX) { unset($data[static::TABLE_CATEGORIES_TYPES]); } return $data; } }