Главная/Статьи/Битрикс: почему не работает CPrice::Update

Битрикс: почему не работает CPrice::Update

Решение проблемы с обновлением цен товаров. CCatalogProduct::Add, проверка существующих цен, правильная последовательность действий.

ДМ
Дмитрий Мещеряков
📅 14 июля 2020 г.📖 7 мин чтения

CPrice::Update() — метод, который умеет молчать. Вызываешь, получаешь true, идёшь в админку — цена прежняя. Ни исключения, ни записи в лог, ни намёка на то, что пошло не так.

Причина почти всегда в одном из трёх мест, и все три связаны с тем, что цена в Битриксе — не поле товара, а отдельная сущность со своим идентификатором. Разберём по порядку.

Проблема

При попытке обновить цену товара методом CPrice::Update() ничего не происходит — цена не изменяется, ошибок нет.

Причины

  1. Элемент не является товаром — не зарегистрирован в таблице b_catalog_product
  2. Неверный ID цены — передаётся ID товара вместо ID записи цены
  3. Цена не существует — нужно создать через CPrice::Add(), а не обновлять

Вторая причина — самая частая, и она же самая коварная из-за того, как ведёт себя API. Метод получает целое число, находит по нему запись в b_catalog_price (или не находит), и отсутствие записи для него не ошибка. С точки зрения CPrice::Update() вы попросили обновить несуществующую строку — он честно ничего не обновил и вернул управление. Ошибку в такой ситуации вернул бы более строгий API; старое ядро Битрикса в этом месте предпочитает молчать.

Правильный алгоритм

php
<?php
use Bitrix\Main\Loader;

Loader::includeModule('catalog');
Loader::includeModule('iblock');

$productId = 123;         // ID элемента инфоблока
$newPrice = 5990.00;      // Новая цена
$priceTypeId = 1;         // ID типа цены (базовая)
$currency = 'RUB';

// Шаг 1: Убеждаемся, что элемент является товаром
if (!\CCatalogProduct::GetByID($productId)) {
    \CCatalogProduct::Add([
        'ID' => $productId,
        'QUANTITY' => 0,
        'QUANTITY_TRACE' => 'N',
    ]);
}

// Шаг 2: Проверяем существование цены.
// QUANTITY_FROM/QUANTITY_TO === false — берём именно базовую строку,
// а не одну из цен для количественных диапазонов
$existingPrice = \CPrice::GetList(
    [],
    [
        'PRODUCT_ID' => $productId,
        'CATALOG_GROUP_ID' => $priceTypeId,
        'QUANTITY_FROM' => false,
        'QUANTITY_TO' => false,
    ]
)->Fetch();

// Шаг 3: Обновляем или добавляем
$priceFields = [
    'PRODUCT_ID' => $productId,
    'CATALOG_GROUP_ID' => $priceTypeId,
    'PRICE' => $newPrice,
    'CURRENCY' => $currency,
];

if ($existingPrice) {
    // Обновляем существующую цену
    // ВАЖНО: передаём ID записи цены, не ID товара!
    $result = \CPrice::Update($existingPrice['ID'], $priceFields);
    echo "Цена обновлена, ID записи: {$existingPrice['ID']}";
} else {
    // Создаём новую цену
    $priceId = \CPrice::Add($priceFields);
    echo "Цена добавлена, ID записи: {$priceId}";
}
⚠️ Важно

Если в каталоге настроены цены по количеству, для одного товара и одного типа цены в b_catalog_price лежит несколько строк: базовая и по одной на каждый диапазон. GetList без явного фильтра по QUANTITY_FROM/QUANTITY_TO вернёт любую из них — какую именно, зависит от порядка в базе. Обновив «первую попавшуюся», вы поменяете цену для диапазона «от 10 штук», а витрина продолжит показывать старую. Симптом ровно тот же, что в начале статьи: «код отработал, цена не изменилась».

Класс для работы с ценами

php
<?php

namespace Local\Catalog;

use Bitrix\Main\Loader;

class PriceManager
{
    private int $defaultPriceTypeId = 1;
    private string $defaultCurrency = 'RUB';

    public function __construct()
    {
        Loader::includeModule('catalog');
    }

    /**
     * Установка цены (создание или обновление)
     */
    public function setPrice(
        int $productId,
        float $price,
        ?int $priceTypeId = null,
        ?string $currency = null
    ): bool {
        $priceTypeId = $priceTypeId ?? $this->defaultPriceTypeId;
        $currency = $currency ?? $this->defaultCurrency;

        // Регистрируем как товар
        $this->ensureProduct($productId);

        $priceFields = [
            'PRODUCT_ID' => $productId,
            'CATALOG_GROUP_ID' => $priceTypeId,
            'PRICE' => $price,
            'CURRENCY' => $currency,
        ];

        $existingPrice = $this->getExistingPrice($productId, $priceTypeId);

        if ($existingPrice) {
            return (bool) \CPrice::Update($existingPrice['ID'], $priceFields);
        }

        return (bool) \CPrice::Add($priceFields);
    }

    /**
     * Получение цены товара
     */
    public function getPrice(int $productId, ?int $priceTypeId = null): ?float
    {
        $priceTypeId = $priceTypeId ?? $this->defaultPriceTypeId;

        $price = $this->getExistingPrice($productId, $priceTypeId);

        return $price ? (float) $price['PRICE'] : null;
    }

    /**
     * Удаление цены
     */
    public function deletePrice(int $productId, ?int $priceTypeId = null): bool
    {
        $priceTypeId = $priceTypeId ?? $this->defaultPriceTypeId;

        $price = $this->getExistingPrice($productId, $priceTypeId);

        if ($price) {
            return \CPrice::Delete($price['ID']);
        }

        return true;
    }

    /**
     * Массовое обновление цен
     */
    public function bulkSetPrices(array $prices, ?int $priceTypeId = null): array
    {
        $results = [
            'success' => 0,
            'failed' => 0,
            'errors' => [],
        ];

        foreach ($prices as $productId => $price) {
            try {
                if ($this->setPrice($productId, $price, $priceTypeId)) {
                    $results['success']++;
                } else {
                    $results['failed']++;
                    $results['errors'][] = "Не удалось обновить цену для товара {$productId}";
                }
            } catch (\Throwable $e) {
                $results['failed']++;
                $results['errors'][] = "Товар {$productId}: {$e->getMessage()}";
            }
        }

        return $results;
    }

    /**
     * Синхронизация цен из свойства инфоблока
     */
    public function syncFromProperty(
        int $iblockId,
        string $propertyCode,
        ?int $priceTypeId = null
    ): array {
        Loader::includeModule('iblock');

        $results = ['updated' => 0, 'skipped' => 0];

        $elements = \CIBlockElement::GetList(
            ['ID' => 'ASC'],
            [
                'IBLOCK_ID' => $iblockId,
                'ACTIVE' => 'Y',
            ],
            false,
            false,
            ['ID', 'PROPERTY_' . $propertyCode]
        );

        while ($element = $elements->Fetch()) {
            $priceValue = $element['PROPERTY_' . $propertyCode . '_VALUE'];

            // Внимание: пустое значение здесь ТОЛЬКО пропускается.
            // Если цену убрали в источнике, в каталоге останется старая —
            // при необходимости добавьте здесь удаление цены
            if (!is_numeric($priceValue) || $priceValue <= 0) {
                $results['skipped']++;
                continue;
            }

            $this->setPrice((int) $element['ID'], (float) $priceValue, $priceTypeId);
            $results['updated']++;
        }

        return $results;
    }

    /**
     * Регистрация элемента как товара
     */
    private function ensureProduct(int $productId): void
    {
        if (!\CCatalogProduct::GetByID($productId)) {
            \CCatalogProduct::Add([
                'ID' => $productId,
                'QUANTITY' => 0,
                'QUANTITY_TRACE' => 'N',
                'CAN_BUY_ZERO' => 'Y',
                'WEIGHT' => 0,
            ]);
        }
    }

    /**
     * Получение существующей записи цены
     */
    private function getExistingPrice(int $productId, int $priceTypeId): ?array
    {
        $result = \CPrice::GetList(
            [],
            [
                'PRODUCT_ID' => $productId,
                'CATALOG_GROUP_ID' => $priceTypeId,
            ]
        );

        return $result->Fetch() ?: null;
    }
}

Использование

php
<?php
use Local\Catalog\PriceManager;

$priceManager = new PriceManager();

// Установка цены одному товару
$priceManager->setPrice(123, 5990.00);

// Получение цены
$price = $priceManager->getPrice(123);
echo "Цена: {$price} руб.";

// Массовое обновление
$prices = [
    100 => 1990.00,
    101 => 2490.00,
    102 => 3990.00,
];

$result = $priceManager->bulkSetPrices($prices);
echo "Обновлено: {$result['success']}, ошибок: {$result['failed']}";

// Синхронизация из свойства
$result = $priceManager->syncFromProperty(2, 'PRICE');
echo "Синхронизировано: {$result['updated']}";

Типы цен

Типы цен настраиваются в админке: Магазин → Настройки → Типы цен.

IDКодНазвание
1BASEБазовая цена
2WHOLESALEОптовая
3VIPVIP
php
// Получение ID типа цены по коду
$priceType = \CCatalogGroup::GetList(
    [],
    ['=NAME' => 'WHOLESALE']
)->Fetch();

$wholesalePriceTypeId = $priceType['ID'];

Консольный скрипт синхронизации

php
<?php
// /local/scripts/sync_prices.php

$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../../');
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Local\Catalog\PriceManager;

$priceManager = new PriceManager();

echo "=== Синхронизация цен из свойства PRICE ===\n\n";

$result = $priceManager->syncFromProperty(
    2,       // ID инфоблока
    'PRICE', // Код свойства
    1        // ID типа цены
);

echo "Обновлено: {$result['updated']}\n";
echo "Пропущено: {$result['skipped']}\n";

Про массовые операции

Класс выше удобен для точечных изменений, но на объёме он развалится, и стоит понимать почему.

Три запроса на товар. ensureProduct() — раз, поиск существующей цены — два, обновление — три. Для десяти товаров незаметно, для пятидесяти тысяч (обычный размер каталога, из-за которого и пишут такие скрипты) — это уже сто пятьдесят тысяч запросов и десятки минут работы.

Выборка целиком в память. syncFromProperty идёт по всем элементам инфоблока без ограничения. На большом каталоге скрипт упрётся либо в memory_limit, либо в max_execution_time, причём в середине процесса — то есть часть цен обновится, часть нет.

Что с этим делать:

  • Обрабатывайте порциями через nTopCount и nPageSize с сохранением позиции, чтобы прерванный импорт можно было продолжить, а не начинать заново.
  • Обновляйте только изменившееся. Сравнение с текущей ценой перед записью убирает 90% запросов на типичном прогоне, где меняется несколько процентов позиций.
  • Отключайте лишнее на время импорта. Обработчики событий, индексация поиска и сброс кэша на каждой позиции превращают импорт в многочасовой.
💡 Совет

Про D7. Bitrix\Catalog\Model\PricePriceTable для чтения) — актуальный API, и на массовых операциях он предпочтительнее. Главное преимущество не в скорости самой по себе, а в предсказуемости: методы возвращают объект результата с ошибками вместо false без объяснений. Старое CPrice API продолжает работать и остаётся уместным в разовых скриптах.

Итоги

Отладочная последовательность, когда цена «не обновляется»:

  1. Есть ли запись в b_catalog_product для этого элемента. Нет — значит это не товар, и цены к нему не привязываются.
  2. Существует ли строка цены нужного типа: SELECT * FROM b_catalog_price WHERE PRODUCT_ID = ... AND CATALOG_GROUP_ID = .... Обновлять нечего — нужен Add.
  3. Тот ли ID вы передаёте. В CPrice::Update() первым параметром идёт ID записи в b_catalog_price, а не ID товара. Совпадение этих чисел на тестовых данных — отдельный источник ложной уверенности.
  4. Не попали ли вы в строку количественного диапазона вместо базовой.
  5. Смотрит ли витрина на тот тип цены, который вы меняете. Код отрабатывает верно, а на сайте выводится другой тип цены — типичный финал этого расследования.
⚠️ Важно

И главное правило по теме: цена в Битриксе — самостоятельная сущность, а не поле товара. Почти все проблемы с ценами через API растут из попытки работать с ней как с полем.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.