Исправления и добавления (#48)
* fix generating merchandise of excluded subcategories, Adding new functionality * fix bugs MoySkladICMLParser.php and fix README.md * fix MoySkladICMLParser.php and README.md * fix readme
This commit is contained in:
parent
c45ee4f80d
commit
4fa66768ad
@ -87,28 +87,27 @@ class MoySkladICMLParser
|
|||||||
*/
|
*/
|
||||||
public function generateICML()
|
public function generateICML()
|
||||||
{
|
{
|
||||||
|
|
||||||
$assortiment = $this->parseAssortiment();
|
$assortiment = $this->parseAssortiment();
|
||||||
$countAssortiment = count($assortiment);
|
$countAssortiment = count($assortiment);
|
||||||
|
$categories = $this->parserFolder();
|
||||||
|
$countCategories = count($categories);
|
||||||
|
|
||||||
if ($countAssortiment > 0) {
|
if ($countCategories > 0) {
|
||||||
$categories = $this->parserFolder();
|
$assortiment = $this->deleteProduct($categories, $assortiment);
|
||||||
} else {
|
} else {
|
||||||
$categories = array();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$icml = $this->ICMLCreate($categories, $assortiment);
|
$icml = $this->ICMLCreate($categories, $assortiment);
|
||||||
$countCategories = count($categories);
|
|
||||||
|
|
||||||
if ($countCategories > 0 && $countAssortiment > 0) {
|
if ($countCategories > 0 && $countAssortiment > 0) {
|
||||||
$icml->asXML($this->getFilePath());
|
$icml->asXML($this->getFilePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $url
|
* @param string $url
|
||||||
* @return JSON
|
* @return string
|
||||||
*/
|
*/
|
||||||
protected function requestJson($url)
|
protected function requestJson($url)
|
||||||
{
|
{
|
||||||
@ -146,11 +145,11 @@ class MoySkladICMLParser
|
|||||||
$result = json_decode($responseBody, true);
|
$result = json_decode($responseBody, true);
|
||||||
|
|
||||||
if ($statusCode >= 400) {
|
if ($statusCode >= 400) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
$this->getError($result) .
|
$this->getError($result) .
|
||||||
" [errno = $errno, error = $error]",
|
" [errno = $errno, error = $error]",
|
||||||
$statusCode
|
$statusCode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
@ -163,6 +162,7 @@ class MoySkladICMLParser
|
|||||||
*/
|
*/
|
||||||
protected function parserFolder()
|
protected function parserFolder()
|
||||||
{
|
{
|
||||||
|
$categories = [];
|
||||||
$offset = 0;
|
$offset = 0;
|
||||||
$end = null;
|
$end = null;
|
||||||
$ignoreCategories = $this->getIgnoreProductGroupsInfo();
|
$ignoreCategories = $this->getIgnoreProductGroupsInfo();
|
||||||
@ -321,6 +321,7 @@ class MoySkladICMLParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
$products[$assortiment['id']] = array(
|
$products[$assortiment['id']] = array(
|
||||||
|
'uuid' => $assortiment['id'],
|
||||||
'id' => !empty($assortiment['product']['externalCode']) ?
|
'id' => !empty($assortiment['product']['externalCode']) ?
|
||||||
($assortiment['product']['externalCode'] . '#' . $assortiment['externalCode']) :
|
($assortiment['product']['externalCode'] . '#' . $assortiment['externalCode']) :
|
||||||
$assortiment['externalCode'],
|
$assortiment['externalCode'],
|
||||||
@ -330,13 +331,6 @@ class MoySkladICMLParser
|
|||||||
'name' => $assortiment['name'],
|
'name' => $assortiment['name'],
|
||||||
'productName'=> isset($assortiment['product']['name']) ?
|
'productName'=> isset($assortiment['product']['name']) ?
|
||||||
$assortiment['product']['name'] : $assortiment['name'],
|
$assortiment['product']['name'] : $assortiment['name'],
|
||||||
'purchasePrice' => isset($assortiment['buyPrice']['value']) ?
|
|
||||||
(((float)$assortiment['buyPrice']['value']) / 100) :
|
|
||||||
(
|
|
||||||
isset($assortiment['product']['buyPrice']['value']) ?
|
|
||||||
(((float)$assortiment['product']['buyPrice']['value']) / 100) :
|
|
||||||
0
|
|
||||||
),
|
|
||||||
'weight' => isset($assortiment['weight']) ?
|
'weight' => isset($assortiment['weight']) ?
|
||||||
$assortiment['weight'] :
|
$assortiment['weight'] :
|
||||||
$assortiment['product']['weight'],
|
$assortiment['product']['weight'],
|
||||||
@ -353,6 +347,29 @@ class MoySkladICMLParser
|
|||||||
''
|
''
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (isset($this->options['customFields'])) {
|
||||||
|
if (!empty($assortiment['attributes'])) {
|
||||||
|
$products[$assortiment['id']]['customFields'] = $this->getCustomFields($assortiment['attributes']);
|
||||||
|
} elseif (!empty($assortiment['product']['attributes'])){
|
||||||
|
$products[$assortiment['id']]['customFields'] = $this->getCustomFields($assortiment['product']['attributes']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($assortiment['barcodes'])){
|
||||||
|
$products[$assortiment['id']]['barcodes'] = $assortiment['barcodes'];
|
||||||
|
} elseif (!empty($assortiment['product']['barcodes'])){
|
||||||
|
$products[$assortiment['id']]['barcodes'] = $assortiment['product']['barcodes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->options['loadPurchasePrice']) && $this->options['loadPurchasePrice'] === true) {
|
||||||
|
if (isset($assortiment['buyPrice']['value'])) {
|
||||||
|
$products[$assortiment['id']]['purchasePrice'] = (((float)$assortiment['buyPrice']['value']) / 100);
|
||||||
|
} elseif (isset($assortiment['product']['buyPrice']['value'])) {
|
||||||
|
$products[$assortiment['id']]['purchasePrice'] = (((float)$assortiment['product']['buyPrice']['value']) / 100);
|
||||||
|
} else {
|
||||||
|
$products[$assortiment['id']]['purchasePrice'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($assortiment['salePrices'][0]['value']) && $assortiment['salePrices'][0]['value'] != 0) {
|
if (isset($assortiment['salePrices'][0]['value']) && $assortiment['salePrices'][0]['value'] != 0) {
|
||||||
$products[$assortiment['id']]['price'] = (((float)$assortiment['salePrices'][0]['value']) / 100);
|
$products[$assortiment['id']]['price'] = (((float)$assortiment['salePrices'][0]['value']) / 100);
|
||||||
@ -407,7 +424,11 @@ class MoySkladICMLParser
|
|||||||
} elseif (isset($assortiment['product']['productFolder']['externalCode'])) {
|
} elseif (isset($assortiment['product']['productFolder']['externalCode'])) {
|
||||||
$products[$assortiment['id']]['categoryId'] = $assortiment['product']['productFolder']['externalCode'];
|
$products[$assortiment['id']]['categoryId'] = $assortiment['product']['productFolder']['externalCode'];
|
||||||
} else {
|
} else {
|
||||||
$products[$assortiment['id']]['categoryId'] = '';
|
$products[$assortiment['id']]['categoryId'] = 'warehouseRoot';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($products[$assortiment['id']]['categoryId'] == 'warehouseRoot') {
|
||||||
|
$this->noCategory = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($assortiment['article'])) {
|
if (isset($assortiment['article'])) {
|
||||||
@ -426,10 +447,6 @@ class MoySkladICMLParser
|
|||||||
$products[$assortiment['id']]['vendor'] = '';
|
$products[$assortiment['id']]['vendor'] = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($products[$assortiment['id']]['categoryId'] == null) {
|
|
||||||
$this->noCategory = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($urlImage != '') {
|
if ($urlImage != '') {
|
||||||
$products[$assortiment['id']]['image']['imageUrl'] = $urlImage;
|
$products[$assortiment['id']]['image']['imageUrl'] = $urlImage;
|
||||||
$products[$assortiment['id']]['image']['name'] =
|
$products[$assortiment['id']]['image']['name'] =
|
||||||
@ -495,14 +512,35 @@ class MoySkladICMLParser
|
|||||||
$this->icmlAdd($offerXml, 'xmlId', $product['xmlId']);
|
$this->icmlAdd($offerXml, 'xmlId', $product['xmlId']);
|
||||||
$this->icmlAdd($offerXml, 'price', number_format($product['price'], 2, '.', ''));
|
$this->icmlAdd($offerXml, 'price', number_format($product['price'], 2, '.', ''));
|
||||||
|
|
||||||
if (!isset($this->options['purchasePrice']) || $this->options['purchasePrice'] != false) {
|
if (isset($product['purchasePrice'])) {
|
||||||
$this->icmlAdd($offerXml, 'purchasePrice', number_format($product['purchasePrice'], 2, '.', ''));
|
$this->icmlAdd($offerXml, 'purchasePrice', number_format($product['purchasePrice'], 2, '.', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($product['barcodes'])) {
|
||||||
|
foreach($product['barcodes'] as $barcode){
|
||||||
|
$this->icmlAdd($offerXml, 'barcode', $barcode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->icmlAdd($offerXml, 'name', htmlspecialchars($product['name']));
|
$this->icmlAdd($offerXml, 'name', htmlspecialchars($product['name']));
|
||||||
$this->icmlAdd($offerXml, 'productName', htmlspecialchars($product['productName']));
|
$this->icmlAdd($offerXml, 'productName', htmlspecialchars($product['productName']));
|
||||||
$this->icmlAdd($offerXml, 'vatRate', $product['effectiveVat']);
|
$this->icmlAdd($offerXml, 'vatRate', $product['effectiveVat']);
|
||||||
|
|
||||||
|
if (!empty($product['customFields'])) {
|
||||||
|
if (!empty($product['customFields']['dimensions'])){
|
||||||
|
$this->icmlAdd($offerXml, 'dimensions', $product['customFields']['dimensions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($product['customFields']['param'])){
|
||||||
|
|
||||||
|
foreach($product['customFields']['param'] as $param){
|
||||||
|
$art = $this->icmlAdd($offerXml, 'param', $param['value']);
|
||||||
|
$art->addAttribute('code', $param['code']);
|
||||||
|
$art->addAttribute('name', $param['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($product['url'])) {
|
if (!empty($product['url'])) {
|
||||||
$this->icmlAdd($offerXml, 'url', htmlspecialchars($product['url']));
|
$this->icmlAdd($offerXml, 'url', htmlspecialchars($product['url']));
|
||||||
}
|
}
|
||||||
@ -515,16 +553,16 @@ class MoySkladICMLParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($product['categoryId']) {
|
if ($product['categoryId']) {
|
||||||
$this->icmlAdd($offerXml, 'categoryId', $product['categoryId']);
|
$this->icmlAdd($offerXml, 'categoryId', $product['categoryId']);
|
||||||
}else {
|
}else {
|
||||||
$this->icmlAdd($offerXml, 'categoryId', 'warehouseRoot');
|
$this->icmlAdd($offerXml, 'categoryId', 'warehouseRoot');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($product['article']) {
|
if ($product['article']) {
|
||||||
$art = $this->icmlAdd($offerXml, 'param', $product['article']);
|
$art = $this->icmlAdd($offerXml, 'param', $product['article']);
|
||||||
$art->addAttribute('code', 'article');
|
$art->addAttribute('code', 'article');
|
||||||
$art->addAttribute('name', 'Артикул');
|
$art->addAttribute('name', 'Артикул');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($product['weight']) {
|
if ($product['weight']) {
|
||||||
if (isset($this->options['tagWeight']) && $this->options['tagWeight'] === true) {
|
if (isset($this->options['tagWeight']) && $this->options['tagWeight'] === true) {
|
||||||
@ -537,26 +575,30 @@ class MoySkladICMLParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($product['code']) {
|
if ($product['code']) {
|
||||||
$cod = $this->icmlAdd($offerXml, 'param', $product['code']);
|
$cod = $this->icmlAdd($offerXml, 'param', $product['code']);
|
||||||
$cod->addAttribute('code', 'code');
|
$cod->addAttribute('code', 'code');
|
||||||
$cod->addAttribute('name', 'Код');
|
$cod->addAttribute('name', 'Код');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($product['vendor']) {
|
if ($product['vendor']) {
|
||||||
$this->icmlAdd($offerXml, 'vendor', $product['vendor']);
|
$this->icmlAdd($offerXml, 'vendor', $product['vendor']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($product['image']['imageUrl']) &&
|
if (isset($product['image']['imageUrl']) &&
|
||||||
!empty($this->options['imageDownload']['pathToImage']) &&
|
!empty($this->options['imageDownload']['pathToImage']) &&
|
||||||
!empty($this->options['imageDownload']['site']))
|
!empty($this->options['imageDownload']['site']))
|
||||||
{
|
{
|
||||||
|
$imgSrc = $this->saveImage($product['image']);
|
||||||
|
|
||||||
|
if (!empty($imgSrc)){
|
||||||
$this->icmlAdd($offerXml, 'picture', $this->saveImage($product['image']));
|
$this->icmlAdd($offerXml, 'picture', $this->saveImage($product['image']));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return $xml;
|
|
||||||
|
|
||||||
|
return $xml;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -648,7 +690,14 @@ class MoySkladICMLParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (file_exists($root . $imgDirrectory . '/' . $image['name']) === false) {
|
if (file_exists($root . $imgDirrectory . '/' . $image['name']) === false) {
|
||||||
$content = $this->requestJson($image['imageUrl']);
|
|
||||||
|
try {
|
||||||
|
$content = $this->requestJson($image['imageUrl']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo $e->getMessage();
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
if ($content) {
|
if ($content) {
|
||||||
file_put_contents($root . $imgDirrectory . '/' . $image['name'], $content);
|
file_put_contents($root . $imgDirrectory . '/' . $image['name'], $content);
|
||||||
@ -746,4 +795,71 @@ class MoySkladICMLParser
|
|||||||
|
|
||||||
return "Internal server error (" . json_encode($result) . ")";
|
return "Internal server error (" . json_encode($result) . ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение массива значений кастомных полей.
|
||||||
|
*
|
||||||
|
* @param array
|
||||||
|
* @return array
|
||||||
|
* @access private
|
||||||
|
*/
|
||||||
|
protected function getCustomFields($attributes) {
|
||||||
|
|
||||||
|
$result = array();
|
||||||
|
|
||||||
|
if (isset($this->options['customFields']['dimensions'])) {
|
||||||
|
if (count($this->options['customFields']['dimensions']) == 3) {
|
||||||
|
$maskArray = $this->options['customFields']['dimensions'];
|
||||||
|
|
||||||
|
foreach($attributes as $attribute){
|
||||||
|
if (in_array($attribute['id'], $this->options['customFields']['dimensions'])){
|
||||||
|
$attributeValue[$attribute['id']] = $attribute['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributeValue = array_merge(array_flip($maskArray),$attributeValue);
|
||||||
|
$result['dimensions'] = implode('/', $attributeValue);
|
||||||
|
|
||||||
|
} elseif (count($this->options['customFields']['dimensions']) == 1) {
|
||||||
|
if (isset($this->options['customFields']['separate'])){
|
||||||
|
foreach($attributes as $attribute){
|
||||||
|
if (in_array($attribute['id'], $this->options['customFields']['dimensions'])){
|
||||||
|
$result['dimensions'] = str_replace($this->options['customFields']['separate'], '/', $attribute['value']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->options['customFields']['paramTag'])) {
|
||||||
|
if ($this->options['customFields']['paramTag']) {
|
||||||
|
foreach ($this->options['customFields']['paramTag'] as $paramTag){
|
||||||
|
$paramTag = explode('#',$paramTag);
|
||||||
|
|
||||||
|
foreach($attributes as $attribute) {
|
||||||
|
|
||||||
|
if ($attribute['id'] == $paramTag[1]) {
|
||||||
|
$result['param'][] = array('code' => $paramTag[0],'name' => $attribute['name'], 'value'=> htmlspecialchars($attribute['value']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteProduct($categories, $products) {
|
||||||
|
foreach ($categories as $category) {
|
||||||
|
$cat[] = $category['externalCode'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
if (!in_array($product['categoryId'],$cat)){
|
||||||
|
unset($products[$product['uuid']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $products;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
40
README.md
40
README.md
@ -59,19 +59,49 @@ e) При необходимости включения в генерацию а
|
|||||||
|
|
||||||
* `file` - Имя файла с итоговым icml без пути (по умолчанию: shopname.catalog.xml)
|
* `file` - Имя файла с итоговым icml без пути (по умолчанию: shopname.catalog.xml)
|
||||||
* `directory` - Директория для итогового icml файла (по умолчанию: текущая директория)
|
* `directory` - Директория для итогового icml файла (по умолчанию: текущая директория)
|
||||||
* `'archivedGoods'` - опция для включения в генерацию архивных товаров и торговых предложений (принимает значения `true` или `false`)
|
* `archivedGoods` - опция для включения в генерацию архивных товаров и торговых предложений (принимает значения `true` или `false`)
|
||||||
* `'purchasePrice'` - флаг для управление генерацией закупочной цены. Если данная опция установлена в `false` то генерация закупочной цены из сервиса Мой Склад производиться не будет.
|
|
||||||
При активной интеграции RetailCRM -> Мой Склад данная опция должна быть `false`.
|
|
||||||
* `ignoreCategories` - массив с ключами:
|
* `ignoreCategories` - массив с ключами:
|
||||||
* `ids` - Массив c `id` групп товаров, которые должны быть проигнорированы
|
* `ids` - Массив c `id` групп товаров, которые должны быть проигнорированы
|
||||||
* `externalCodes` - Массив c `внешними кодами` групп товаров, которые должны быть проигнорированы
|
* `externalCode` - Массив c `внешними кодами` групп товаров, которые должны быть проигнорированы
|
||||||
* `ignoreNoCategoryOffers` - Если `true` товары, не принадлежащие ни к одной категории, будут проигнорированы
|
* `ignoreNoCategoryOffers` - Если `true` товары, не принадлежащие ни к одной категории, будут проигнорированы
|
||||||
* `imageDownload` - массив, содержащий информацию для загрузки изображений
|
* `imageDownload` - массив, содержащий информацию для загрузки изображений
|
||||||
* `site` - адрес сайта откуда будут отдаваться изображения в retailCRM
|
* `site` - адрес сайта откуда будут отдаваться изображения в retailCRM
|
||||||
* `pathToImage` - путь от корня сайта до дирректории где будут храниться изображения
|
* `pathToImage` - путь от корня сайта до дирректории где будут храниться изображения
|
||||||
* `tagWeight` - передача веса в теге `weight` вместо `param`. Единица измерения - килограмм.
|
* `tagWeight` - передача веса в теге `weight` вместо `param`. Единица измерения - килограмм.
|
||||||
Формат: положительное число с точностью 0.001 (или 0.000001, в зависимости от настройки RetailCRM "Точность веса": граммы или миллиграммы соответственно), разделитель целой и дробной части - точка.
|
Формат: положительное число с точностью 0.001 (или 0.000001, в зависимости от настройки RetailCRM "Точность веса": граммы или миллиграммы соответственно), разделитель целой и дробной части - точка.
|
||||||
Указывается в свойствах товара сервиса Мой Склад
|
Указывается в свойствах товара сервиса Мой Склад.
|
||||||
|
* `loadPurchasePrice` - установка данной опции со значением `true` включает в генерацию закупочные цены. По умолчанию закупочные цены для товаров не генерируются.
|
||||||
|
* `customFields` - массив для указания для генерации габаритов (dimensions) и дополнительных параметров товаров. Включает в себя следующие опции:
|
||||||
|
* `dimensions` - массив с одним или тремя значениями, содержащий id пользовательских полей товара в МС. При указании 3 полей должен соблюдаться порядок 'Длина,Ширина,Высота'.
|
||||||
|
Пример заполнения:
|
||||||
|
|
||||||
|
`'dimensions' =>
|
||||||
|
[
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
'00000000-0000-0000-0000-000000000000'
|
||||||
|
]`
|
||||||
|
|
||||||
|
Если для генерации планируется использовать одно поле, то нужно использовать дополнительный параметр `separate` в котором вы должны указать какой разделитель используется в поле между
|
||||||
|
значениями на стороне МС. Пример заполнения:
|
||||||
|
`
|
||||||
|
'separate' => '/',
|
||||||
|
'dimensions' =>
|
||||||
|
[
|
||||||
|
'00000000-0000-0000-0000-000000000000'
|
||||||
|
]
|
||||||
|
`
|
||||||
|
|
||||||
|
* `paramTag` - массив со значениями,складывающимися из кода, который должен использоваться для генерации данного дополнительного параметра и id пользовательского поля товара. Заполняется с разделетелем "#" следующим образом:
|
||||||
|
|
||||||
|
`'paramTag'=>
|
||||||
|
[
|
||||||
|
'somecode1#00000000-0000-0000-0000-000000000000',
|
||||||
|
'somecode2#00000000-0000-0000-0000-000000000000'
|
||||||
|
]`
|
||||||
|
|
||||||
|
Id пользовательских свойств товара можно получить, совершив GET-запрос к api МС по адресу `https://online.moysklad.ru/api/remap/1.1/entity/product/metadata`, используя для запроса ваш логин и пароль, используемый для генерации каталога.
|
||||||
|
Необходимые id будут указаны внутри индекса "attributes".
|
||||||
Все доступные опции не обязательны для использования
|
Все доступные опции не обязательны для использования
|
||||||
|
|
||||||
## Добавление изображения
|
## Добавление изображения
|
||||||
|
Loading…
x
Reference in New Issue
Block a user