singletonIf('listings.item.dynprops', function () { $dynprops = new static( 'cat_id', Listings::TABLE_CATEGORIES, Listings::TABLE_CATEGORIES_DYNPROPS, Listings::TABLE_CATEGORIES_DYNPROPS_MULTI, 1 # полное наследование ); $dynprops->setSettings(bff::filter('listings.dp.settings', [ 'module_name' => 'listings', 'viewsPath' => Listings::i()->module_dir_tpl, 'typesAllowed' => [ static::typeCheckboxGroup, static::typeRadioGroup, static::typeRadioYesNo, static::typeCheckbox, static::typeSelect, static::typeInputText, static::typeTextarea, static::typeNumber, static::typeRange, ], 'langs' => Lang::getLanguages(false), 'langText' => [ 'yes' => _t('listings', 'yes'), 'no' => _t('listings', 'No'), 'all' => _t('listings', 'All'), 'select' => _t('', 'Select'), ], 'langTextTypes' => Listings::translate(), 'typesAllowedParent' => [static::typeSelect], /** * Настройки доступных int/text столбцов динамических свойств для хранения числовых/тестовых данных. * При изменении, не забыть привести в соответствие столбцы f(1-n) в таблице TABLE_ITEMS */ 'datafield_int_last' => 15, 'datafield_text_first' => 16, 'datafield_text_last' => 20, 'searchRanges' => true, 'cacheKey' => true, ], $dynprops)); $dynprops->extraSettings(bff::filter('listings.dp.settings.extra', [ 'in_seek' => [ 'title' => _t('listings', 'Fill in "Demand" type listings'), 'input' => 'checkbox', ], 'num_first' => [ 'title' => _t('listings', 'Display before inherited (first)'), 'input' => 'checkbox', ], ], $dynprops)); $dynprops->setCurrentLanguage(Lang::current()); return $dynprops; }); return bff('listings.item.dynprops'); } /** * Получаем дин. свойства категории * @param int $categoryID ID категории * @param array $opts * @return array|bool */ public function settings(int $categoryID, array $opts = []) { if ($categoryID <= 0) { return []; } $opts = $this->defaults($opts, [ 'reset' => false, # сбросить кеш настроек дин. свойств категории 'lang' => Lang::current(), ]); $cacheKey = 'cat-dynprops-{lang}-' . $categoryID; if ($opts['reset']) { foreach ($this->locale->getLanguages() as $lang) { Cache::delete(str_replace('{lang}', $lang, $cacheKey)); } return true; } $cacheKey = str_replace('{lang}', $opts['lang'], $cacheKey); return Cache::rememberForever($cacheKey, function () use ($categoryID) { return $this->getByOwner($categoryID, true, true, false); }); } /** * Событие изменения настроек дин. свойств категории * @param int $categoryID id категории * @param int $dynpropID id дин.свойства * @param string $event событие, генерирующее вызов метода * @return bool|void */ public function onSettingsChanged(int $categoryID, int $dynpropID, string $event) { if (empty($categoryID)) { return false; } $this->app->hook('listings.dp.settings.changed', $categoryID, $dynpropID, $event); $this->settings($categoryID, ['reset' => true]); } /** * Обновления кеша дин. свойств * @param string $event тип события, генерирующего обновление * @param array $params дополнительные параметры * @return void */ protected function updateCache($event, array $params) { parent::updateCache($event, $params); $this->onSettingsChanged($params['owner'], $params['id'], $event); } /** * Формирование SQL запроса для сохранения дин.свойств * @param int $categoryID ID подкатегории * @param string $fieldName ключ в POST данных * @return array */ public function onSave(int $categoryID, $fieldName = 'd', $data = null) { if (is_null($data)) { $data = $this->input->post($fieldName, TYPE_ARRAY); } $dynpropsData = []; foreach ($data as $props) { foreach ($props as $id => $v) { $dynpropsData[$id] = $v; } } $dynprops = $this->getByOwner($categoryID, true, true); $dynpropsPlus = array_diff_key($dynpropsData, $dynprops); if (! empty($dynpropsPlus)) { $dynpropsPlus = $this->getByID(array_keys($dynpropsPlus), true); foreach ($dynpropsPlus as $k => $v) { if (empty($dynprops[$k])) { $dynprops[$k] = $v; } } } return $this->prepareSaveDataByID( $dynpropsData, $dynprops, 'update', true ); } /** * Подготовка запроса полей дин. свойств на основе значений "cache_key" * @param int $categoryID ID категории * @param string $prefix префикс таблицы, например "I." * @return string */ public function prepareSelectFieldsQuery(int $categoryID = 0, string $prefix = '') { if (! $this->cacheKey) { return ''; } if (empty($categoryID) || $categoryID < 0) { return ''; } $fields = []; foreach ($this->settings($categoryID) as $v) { $f = $prefix . $this->datafield_prefix . $v['data_field']; if (!empty($v['cache_key'])) { $f .= ' as ' . $v['cache_key']; } $fields[] = $this->db->wrapColumn($f); } return (!empty($fields) ? join(', ', $fields) : ''); } /** * Подготовка данных для заполнения шаблона автозаголовка * @param int $categoryID категория * @param string $format шаблон * @param int|array $dp @ref дин. свойства * @param string|null $lang язык * @return array данные для заполнения шаблона */ public function prepareItemTemplateData(int $categoryID, string $format, &$dp = 0, ?string $lang = null) { $langCurrent = $this->locale->current(); if (empty($lang)) { $lang = $langCurrent; } $key = 'd'; # Получим и кешируем дин. св. для категории $cat = $this->itemsTemplatesCache['cat'] ?? []; if (! isset($cat[$categoryID][$lang])) { if ($lang != $langCurrent) { $this->setCurrentLanguage($lang); } $this->itemsTemplatesCache['cat'][$categoryID][$lang] = $this->getByOwner($categoryID, true, true, false); $cat = $this->itemsTemplatesCache['cat']; if ($lang != $langCurrent) { $this->setCurrentLanguage($langCurrent); } } $dp = $cat[$categoryID][$lang]; $result = []; # игнорируем макросы offer и seek $format = strtr($format, [':offer' => '', ':seek' => '', '.sub' => '']); foreach ($dp as $k => $v) { # добавим дин. св. найденное по хеш ключу if (! empty($v['cache_key']) && mb_strpos($format, '{' . $v['cache_key'] . '}') !== false) { $result[ $v['cache_key'] ] = [ 'name' => $key . '[' . $v['cat_id'] . '][' . $v['id'] . ']', 'keys' => [$key, $v['cat_id'], $v['id']], 'id' => $v['id'], 'f' => $this->datafield_prefix . $v['data_field'], ]; } # добавим дин. св. найденное по ID if (mb_strpos($format, '{' . $v['id'] . '}') !== false) { $result[ $v['id'] ] = [ 'name' => $key . '[' . $v['cat_id'] . '][' . $v['id'] . ']', 'keys' => [$key, $v['cat_id'], $v['id']], 'id' => $v['id'], 'f' => $this->datafield_prefix . $v['data_field'], ]; } } return $result; } /** * Формирование автозаголовка по шаблону * @param int $categoryID категория * @param string|null $format шаблон * @param array $data данные для заполнения * @param string|null $lang язык * @return string */ public function buildItemTemplate(int $categoryID, ?string $format, array $data = [], ?string $lang = null) { $langCurrent = $this->locale->current(); if (empty($lang)) { $lang = $langCurrent; } # Извлечение значения из данных по ключу (рекурсивно для массивов d[13][91]) $extractValue = function ($data, $key) use (&$extractValue) { if (is_array($key)) { $fst = reset($key); if (isset($data[$fst])) { if (count($key) > 1) { array_shift($key); return $extractValue($data[$fst], $key); } else { return is_array($data[$fst]) ? $data[$fst] : $this->input->cleanTextPlain($data[$fst]); } } } else { if (isset($data[$key])) { return is_array($data[$key]) ? $data[$key] : $this->input->cleanTextPlain($data[$key]); } } return false; }; # Получение названия городов с кешированием $city = function ($id, $field) use ($lang) { $cityCache = $this->itemsTemplatesCache['cityCache'] ?? []; $key = false; if (is_array($field)) { if (isset($field[1])) { $key = $field[1]; } $field = reset($field); } if (is_array($field)) { return ''; } if (! isset($cityCache[$id][$lang])) { $this->itemsTemplatesCache['cityCache'][$id][$lang] = Geo::model()->regionData(['id' => $id], ['lang' => $lang]); $cityCache = $this->itemsTemplatesCache['cityCache']; } if (! isset($cityCache[$id][$lang][$field])) { return ''; } if (is_array($cityCache[$id][$lang][$field])) { if ($key && isset($cityCache[$id][$lang][$field][$key])) { return $cityCache[$id][$lang][$field][$key]; } return $cityCache[$id][$lang][$field] ?? ''; } return $cityCache[$id][$lang][$field]; }; # Получение названия станций метро с кешированием $metro = function ($id) use ($lang) { $metroCache = $this->itemsTemplatesCache['metroCache'] ?? []; if (! isset($metroCache[$id])) { $this->itemsTemplatesCache['metroCache'][$id] = Geo::model()->metroStationsListing(['id' => $id], ['oneArray' => true, 'lang' => $lang]); $metroCache = $this->itemsTemplatesCache['metroCache']; } return $metroCache[$id]['title'] ?? ''; }; # Получение названия районов с кешированием $district = function ($id) use ($lang) { $districtsCache = $this->itemsTemplatesCache['districtsCache'] ?? []; if (! isset($districtsCache[$id])) { $this->itemsTemplatesCache['districtsCache'][$id] = Geo::model()->districtsListing(['id' => $id], ['oneArray' => true, 'lang' => $lang]); $districtsCache = $this->itemsTemplatesCache['districtsCache']; } return $districtsCache[$id]['title'] ?? ''; }; # Получение названия категорий с кешированием $catTitle = function ($id) use ($lang) { $catCache = $this->itemsTemplatesCache['catCache'] ?? []; if (! isset($catCache[$id][$lang])) { $this->itemsTemplatesCache['catCache'][$id][$lang] = Listings::model()->catDataByFilter(['id' => $id, 'lang' => $lang], ['title']); $catCache = $this->itemsTemplatesCache['catCache']; } return $catCache[$id][$lang]['title'] ?? ''; }; # Получение парент категорий с кешированием $catPath = function ($id) use ($lang) { $pathCache = $this->itemsTemplatesCache['pathCache'] ?? []; if (! isset($pathCache[$id][$lang])) { $data = Listings::model()->catParentsData($id, ['id','title','numlevel'], ['lang' => $lang]); if (! empty($data)) { $this->itemsTemplatesCache['pathCache'][$id][$lang] = []; foreach ($data as $v) { $this->itemsTemplatesCache['pathCache'][$id][$lang][ $v['numlevel'] ] = $v['title']; } } $pathCache = $this->itemsTemplatesCache['pathCache']; } return isset($pathCache[$id][$lang]) ? $pathCache[$id][$lang] : []; }; # Получение прикрепленных дин. свойств по значению с кешированием $dpChild = function ($parent, $value) use ($lang) { $dpChildren = $this->itemsTemplatesCache['dpChildren'] ?? []; if (! isset($dpChildren[$parent][$value][$lang])) { $this->setCurrentLanguage($lang); $res = $this->getByParentIDValuePairs([['parent_id' => $parent, 'parent_value' => $value]]); if (isset($res[$parent][$value])) { $this->itemsTemplatesCache['dpChildren'][$parent][$value][$lang] = $res[$parent][$value]; } $dpChildren = $this->itemsTemplatesCache['dpChildren'] ?? []; } return $dpChildren[$parent][$value][$lang] ?? []; }; $result = ''; if (empty($format)) { return ''; } $this->setCurrentLanguage($lang); $prepare = $this->prepareItemTemplateData($categoryID, $format, $dp, $lang); # данные для заполнения $view = explode('|', $format); # разделитель полей в шаблоне foreach ($view as $v) { preg_match_all('/\{([\w:\-\.]+)\}/', $v, $m); if (! empty($m[1])) { foreach ($m[1] as $kk => $vv) { # обработаем макросы offer и seek $pos = mb_strpos($vv, ':'); if ($pos !== false) { $offerSeek = mb_substr($vv, $pos + 1); $vv = mb_substr($vv, 0, $pos); if (! isset($data['cat_type'])) { continue 2; } switch ($offerSeek) { case 'offer': if ($data['cat_type'] != Listings::TYPE_OFFER) { continue 3; } break; case 'seek': if ($data['cat_type'] != Listings::TYPE_SEEK) { continue 3; } break; } } # прикрепленное дин св. $dpsub = false; $pos = mb_strpos($vv, '.sub'); if ($pos !== false) { $dpsub = true; $vv = mb_substr($vv, 0, $pos); } $val = ''; # заполняем для дин. свойств if (isset($prepare[$vv])) { $keys = $prepare[$vv]['keys']; $id = $prepare[$vv]['id']; if (isset($data[ $prepare[$vv]['f'] ])) { $val = $data[ $prepare[$vv]['f'] ]; } else { $val = $extractValue($data, $keys); } if ($dpsub) { # для прикрепленного if (empty($dp[$id]['parent']) || empty($dp[$id]['multi'])) { continue 2; } $child = $dpChild($id, $val); # данные о выбранном прикрепленном дин. св. if (empty($child['id']) || empty($child['multi'])) { continue 2; } $val = ''; $fn = $this->datafield_prefix . $child['data_field']; if (isset($data[ $fn ])) { $childVal = $data[ $fn ]; } else { # заменим id исходного на id выбранного прикрепленного $childKeys = $keys; foreach ($childKeys as &$vvv) { if ($vvv == $id) { $vvv = $child['id']; } } unset($vvv); $childVal = $extractValue($data, $childKeys); # значение выбранного прикрепленного дин. св. } foreach ($child['multi'] as $ml) { if ($ml['value'] == $childVal) { $val = $ml['name']; break; } } if (empty($val)) { continue 2; } } else { if (empty($val)) { continue 2; } if (!empty($data['cat_type']) && empty($dp[$id]['in_seek'])) { continue 2; } if (!empty($dp[$id]['multi'])) { if (is_array($val)) { $aval = $val; $val = ''; $ares = []; foreach ($dp[$id]['multi'] as $ml) { if (in_array($ml['value'], $aval)) { $ares[] = $ml['name']; } } if (! empty($ares)) { $val = join(', ', $ares); } } else { $val = (int)$val; $ares = []; foreach ($dp[$id]['multi'] as $ml) { if (in_array($dp[$id]['type'], [static::typeSelectMulti, static::typeCheckboxGroup])) { if ((int)$ml['value'] & $val) { $ares[] = $ml['name']; } } else { if ($ml['value'] == $val) { $val = $ml['name']; break; } } } if (! empty($ares)) { $val = join(', ', $ares); } } } else { switch ($dp[$id]['type']) { case Dynprops::typeRadioYesNo: $val = ($val == 2 ? $this->langText['yes'] : ($val == 1 ? $this->langText['no'] : '')); break; } } } } else { # общие поля для любой категории switch ($vv) { case 'price': if (empty($data['price'])) { continue 3; } if (empty($data['price_curr'])) { continue 3; } $this->input->clean($data['price'], TYPE_PRICE); if (empty($data['price'])) { continue 3; } $val = Currency::formatPriceAndCurrency($data['price'], $data['price_curr'], ['lang' => $lang]); break; case 'category': $val = $catTitle($categoryID); if (empty($val)) { continue 3; } break; case 'geo.city': if (empty($data['geo_city'])) { continue 3; } $val = $city($data['geo_city'], 'title'); break; case 'geo.city.in': if (empty($data['geo_city'])) { continue 3; } $val = $city($data['geo_city'], ['declension', 'where']); break; case 'geo.metro': if (empty($data['metro_id'])) { continue 3; } $val = $metro($data['metro_id']); break; case 'geo.district': if (empty($data['district_id'])) { continue 3; } $val = $district($data['district_id']); break; default: if (mb_substr($vv, 0, 9) == 'category-') { $n = mb_substr($vv, 9); $path = $catPath($categoryID); if (empty($path)) { break; } if ($n == 'parent') { end($path); $val = prev($path); } else { $n = intval($n); if (isset($path[$n])) { $val = $path[$n]; } } } elseif (mb_substr($vv, 0, 11) == 'description') { $len = 100; $val = ''; $p = mb_strpos($vv, '.'); if ($p) { $p = (int)mb_substr($vv, $p + 1); if ($p > 5) { $len = $p; } } $descr = ''; if (isset($data['translates']['descr'])) { $descr = $data['translates']['descr']; } if (empty($descr) && isset($data['descr'])) { $descr = $data['descr']; } if (! empty($descr)) { if (is_array($descr)) { if (isset($descr[$lang])) { $descr = $descr[$lang]; } else { $descr = reset($descr); } } $descr = $this->input->clean($descr, TYPE_NOTAGS); $val = tpl::truncate($descr, $len); } if (empty($val)) { continue 3; } } break; } } if ($val) { $v = str_replace($m[0][$kk], $val, $v); } } } $result .= $v; } return $result; } /** * Формирование формы редактирования/фильтра дин.свойств * @param int $categoryID ID категории * @param array $data данные * @param array $opts * @return string HTML template */ public function onForm(int $categoryID, array $data = [], array $opts = []) { if (empty($categoryID)) { return ''; } $opts = $this->defaults($opts, [ 'extra' => [], 'search' => false, 'prefix' => 'd', 'adminPanel' => $this->isAdminPanel(), ]); if ($opts['adminPanel']) { if ($opts['search']) { $form = $this->form($categoryID, $data, true, true, $opts['prefix'], 'search.inline', false, $opts['extra']); } else { if (! empty($data['moderated_data'])) { $opts['extra']['compare'] = $data['moderated_data']; } $form = $this->form($categoryID, $data, true, false, $opts['prefix'], 'form.table', false, $opts['extra']); } } else { if (!$opts['search']) { $form = $this->form($categoryID, $data, true, false, $opts['prefix'], 'item.form.dp', $this->viewsPath, $opts['extra']); } } return (!empty($form['form']) ? $form['form'] : ''); } /** * Отображение дин. свойств * @param int $categoryID ID категории * @param array $data данные * @param array $opts * @return string|array */ public function onView(int $categoryID, array $data, array $opts = []) { $opts = $this->defaults($opts, [ 'prefix' => 'd', 'dataOnly' => false, # bool|int только данные (-1 - полные данные) 'adminPanel' => $this->isAdminPanel(), ]); if (! $opts['adminPanel']) { $form = $this->form($categoryID, $data, true, false, $opts['prefix'], 'item/dynprops', $this->viewsPath); } else { $form = $this->form($categoryID, $data, true, false, $opts['prefix'], 'view.table'); } if ($opts['dataOnly']) { if ($opts['dataOnly'] === -1) { return $form; } return (!empty($form['data']) ? $form['data'] : []); } return (!empty($form['form']) ? $form['form'] : ''); } /** * Seo placeholders by category id * @param int $categoryId * @return array */ public function getSeoTemplatePlaceholders($categoryId) { if (! $categoryId) { return []; } $dynprops = $this->getByOwner($categoryId, true, true, false); if (empty($dynprops)) { return []; } $placeholders = []; foreach ($dynprops as $id => $prop) { $required = $prop['req'] ?? false; $cacheKey = $prop['cache_key'] ?? ''; if (! empty($cacheKey)) { $placeholders[$cacheKey] = [ 'title' => $prop['title'] . ($required ? ' *' : ''), 'insert' => ( ! $required ? ' | {' . $cacheKey . '} | ' : false), ]; } $placeholders[$id] = [ 'title' => $prop['title'] . ($required ? ' *' : ''), 'insert' => ( ! $required ? ' | {' . $id . '} | ' : false), ]; if ($prop['parent']) { if (! empty($cacheKey)) { $placeholders[$cacheKey . '.sub'] = [ 'title' => $prop['child_title'] . ($required ? ' *' : ''), 'insert' => (! $required ? ' | {' . $cacheKey . '.sub} | ' : false), ]; } $placeholders[$id . '.sub'] = [ 'title' => $prop['child_title'] . ($required ? ' *' : ''), 'insert' => (! $required ? ' | {' . $id . '.sub} | ' : false) ]; } } return $placeholders; } }