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