# Складской учёт и остатки: синхронизация без гонок

URL: https://dmeshcheryakov.ru/blog/bitrix-warehouses-stock-sync/
Раздел: 1С-Битрикс
Теги: 1С-Битрикс, Каталог, Интеграции, База данных, Архитектура
Опубликовано: 2026-10-28
Обновлено: 2026-10-28
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

> Почему обмен и корзина затирают записи друг друга, откуда берутся отрицательные остатки и чем абсолютная запись остатка отличается от инкрементальной.

Склад говорит, что смесителей семнадцать. Сайт показывает двадцать четыре. К вечеру сайт показывает уже тридцать один, хотя продажи шли весь день и ничего не приходило.

Числа не просто разные — они расходятся в неправильную сторону. Это не ошибка выгрузки и не сбой 1С. Это гонка: два процесса пишут в одну ячейку, каждый по-своему прав, и побеждает тот, кто записал последним.

Проблема архитектурная, и настройками она не лечится. Зато она хорошо диагностируется и имеет решение, которое стоит заложить до того, как магазин вырастет.

## Две модели записи, которые не совместимы

Остаток товара изменяют два источника, и они работают принципиально по-разному.

**Обмен с 1С пишет абсолютное значение.** «На складе 17 штук». Он не знает и не хочет знать, что было на сайте раньше: 1С — источник истины, её число замещает предыдущее.

**Заказы изменяют относительно.** «Стало на 2 меньше». Корзина читает текущее значение, вычитает проданное, записывает результат.

Абсолютная запись и относительное изменение одной ячейки конфликтуют по построению. Достаточно, чтобы они пересеклись во времени:

```
10:00:00  Обмен читает: 17
10:00:05  Покупатель заказывает 2 → заказ пишет: 15
10:00:40  Обмен записывает прочитанное ранее: 17   ← продажа затёрта
```

Обмен не делал ничего неправильного. Он записал то, что было верно в момент чтения. Просто между чтением и записью прошло сорок секунд, и мир изменился.

## Сколько это стоит

Масштаб потерь прямо зависит от длительности обмена и интенсивности продаж:

| Длительность обмена | Заказов в час | Теряется продаж за обмен |
|---|---:|---:|
| 2 минуты | 30 | ~1 |
| 10 минут | 30 | ~5 |
| 40 минут | 30 | ~20 |
| 40 минут | 200 | ~130 |

И это за **один** обмен. При обмене каждые пятнадцать минут ошибка не разовая, а накапливающаяся: сайт систематически показывает больше товара, чем есть, магазин продаёт то, чего нет, и получает отмены.

Отсюда неочевидная связь: [ускорение обмена](/blog/bitrix-catalog-import-performance) — это не только про нагрузку на сервер, но и про точность остатков. Обмен, идущий две минуты вместо сорока, уменьшает окно гонки в двадцать раз.

## Диагностика

Три запроса, от самого быстрого к самому подробному.

### Отрицательные остатки

```sql
SELECT ID, QUANTITY, QUANTITY_RESERVED
FROM b_catalog_product
WHERE QUANTITY < 0
ORDER BY QUANTITY ASC
LIMIT 50;
```

Это индикатор номер один. **Отрицательный остаток не бывает нормой ни при каком сценарии.** Если запрос что-то вернул, гонка уже происходит, и дальше искать подтверждений не нужно.

### Расхождение складов и общего остатка

При многоскладовом учёте сумма остатков по складам должна сходиться с общим остатком товара:

```sql
SELECT
    p.ID,
    p.QUANTITY                        AS total_quantity,
    COALESCE(SUM(sp.AMOUNT), 0)       AS sum_by_stores,
    p.QUANTITY - COALESCE(SUM(sp.AMOUNT), 0) AS diff
FROM b_catalog_product p
LEFT JOIN b_catalog_store_product sp ON sp.PRODUCT_ID = p.ID
GROUP BY p.ID, p.QUANTITY
HAVING ABS(diff) > 0.001
ORDER BY ABS(diff) DESC
LIMIT 50;
```

**Сравнение дробных чисел через `> 0.001`, а не через `!= 0`.** Остатки бывают дробными — метры, килограммы, площадь плитки, — а дробная арифметика в базе даёт хвосты в последних знаках. Строгое сравнение с нулём выдаст сотни ложных срабатываний на товарах, где расхождения нет, и настоящие проблемы в этом шуме потеряются.

### Сверка с выгрузкой

```bash
# Остаток конкретного товара в том, что прислала 1С
grep -A 12 "f4e2c1a9" /home/site/www/upload/1c_catalog/offers.xml | grep "Количество"
```

Сравниваем с базой. Расхождение сразу после обмена означает проблему разбора, расхождение к концу дня — гонку.

## Решение 1: разделить источники истины

Правильный подход, убирающий причину, а не симптом.

Идея в том, чтобы обмен и заказы **перестали писать в одну ячейку**:

| Поле | Кто пишет | Что означает |
|---|---|---|
| Остаток склада | Только обмен | Сколько физически лежит по данным 1С |
| Резерв | Только заказы | Сколько отложено под неотгруженные заказы |
| Доступно к продаже | Никто, вычисляется | Остаток минус резерв |

Теперь гонки нет по построению. Обмен пишет своё поле, заказы — своё, а витрина показывает разность.

```php
<?php
/**
 * Доступное к продаже считается, а не хранится. Хранимое значение
 * пришлось бы обновлять из двух мест — то есть возвращать гонку обратно.
 */
function getAvailableQuantity(array $product): float
{
    $stock    = (float)($product['QUANTITY'] ?? 0);
    $reserved = (float)($product['QUANTITY_RESERVED'] ?? 0);

    return max(0.0, $stock - $reserved);
}
```

**Резерв нужно снимать, а не только ставить.** Отменённый заказ, брошенная корзина, истёкшая бронь — каждый такой случай обязан освобождать резерв. Забытое снятие даёт медленно растущий фантомный резерв: товар физически лежит на складе, а сайт показывает «нет в наличии». Диагностируется это сравнением суммы резервов с суммой по активным заказам, и делать такую сверку стоит регулярно, а не когда придёт жалоба.

## Решение 2: блокировка

Когда разделить поля нельзя — например, магазин уже работает и переделка означает переписывание корзины, — остаётся защищать запись блокировкой.

Ключевое требование: блокировать **строку**, а не таблицу, и внутри транзакции.

```php
<?php
use Bitrix\Main\Application;

/**
 * Атомарное изменение остатка: читаем и пишем внутри одной транзакции,
 * заблокировав строку. Параллельный процесс подождёт, а не затрёт.
 */
function decreaseQuantity(int $productId, float $amount): bool
{
    $connection = Application::getConnection();
    $connection->startTransaction();

    try {
        // FOR UPDATE удерживает строку до конца транзакции
        $row = $connection->query(
            "SELECT QUANTITY FROM b_catalog_product WHERE ID = {$productId} FOR UPDATE"
        )->fetch();

        if (!$row) {
            $connection->rollbackTransaction();
            return false;
        }

        $current = (float)$row['QUANTITY'];
        if ($current < $amount) {
            $connection->rollbackTransaction();
            return false;   // нечего списывать — это не ошибка, а отказ
        }

        $new = $current - $amount;
        $connection->queryExecute(
            "UPDATE b_catalog_product SET QUANTITY = {$new} WHERE ID = {$productId}"
        );

        $connection->commitTransaction();
        return true;
    } catch (\Throwable $e) {
        $connection->rollbackTransaction();
        throw $e;
    }
}
```

**Блокировка строки в обмене означает, что обмен и заказы ждут друг друга.** На каталоге в сто тысяч товаров обмен будет последовательно удерживать строки, а покупатели — стоять в очереди на те же товары. Это лечит корректность ценой отзывчивости, и на активном магазине лекарство может оказаться хуже болезни. Прежде чем ставить блокировки в обмен, замерьте, сколько он держит строку: если это доли миллисекунды на товар — приемлемо, если секунды — нет.

## Решение 3: сузить окно

Полумера, но дешёвая и совместимая с любым из решений выше.

**Обменивать остатки отдельно от каталога.** Файл с остатками лёгкий, разбирается быстро, окно гонки сокращается кратно. Разделение `import.xml` и `offers.xml` по интервалам разобрано в статье про [расписание обмена](/blog/bitrix-1c-exchange-schedule-cron).

**Не писать то, что не изменилось.** Обмен, обновляющий все сто тысяч товаров ради двухсот изменившихся, держит окно открытым в пятьсот раз дольше необходимого.

**Обновлять остатки в конце обмена, а не в начале.** Чем ближе запись к моменту чтения, тем меньше расхождение.

## Многоскладовость

Отдельный слой сложности, который добавляет ещё одну возможность разойтись: остатки по складам и общий остаток товара хранятся раздельно.

```php
<?php
use Bitrix\Catalog\StoreTable;
use Bitrix\Main\Loader;

Loader::includeModule('catalog');

$stores = StoreTable::getList([
    'select' => ['ID', 'TITLE', 'ACTIVE', 'SHIPPING_CENTER', 'XML_ID'],
    'order'  => ['SORT' => 'ASC'],
])->fetchAll();

foreach ($stores as $store) {
    printf(
        "%3d  %-28s активен=%s  xml_id=%s\n",
        $store['ID'],
        $store['TITLE'],
        $store['ACTIVE'],
        $store['XML_ID'] ?: '— НЕ СОПОСТАВЛЕН —'
    );
}
```

Пустой `XML_ID` склада означает то же, что и у типа цены: обмен не найдёт соответствия и **молча** проигнорирует остатки по этому складу. Механика молчаливого игнорирования разобрана в статье про [цены из 1С](/blog/bitrix-1c-prices-types-currencies) — здесь она работает точно так же.

Второй частый случай — склад, который есть в 1С, но не отмечен как отгрузочный на сайте. Остатки на нём хранятся, в расчёт доступности не идут, и получается «товар есть, но купить нельзя».

## Что мониторить постоянно

Четыре проверки, которые стоит выполнять по расписанию и отправлять отчётом:

| Что | Норма | О чём говорит нарушение |
|---|---|---|
| Отрицательные остатки | 0 | Гонка при записи |
| Сумма складов ≠ общий остаток | 0 позиций | Рассинхрон складского учёта |
| Резерв без активного заказа | 0 | Резервы не снимаются |
| Склады с пустым `XML_ID` | 0 | Остатки склада игнорируются |

Первая строка — минимально необходимое. Если поставить только один автоматический контроль во всём складском учёте, ставить надо именно её.

## Итоги

**Абсолютная запись и относительное изменение одной ячейки несовместимы.** Это не ошибка реализации, а следствие двух разных моделей, встретившихся в одном поле.

**Отрицательный остаток — доказательство гонки.** Не симптом, не совпадение, не «особенность». Один запрос даёт ответ.

**Правильное решение — разделить поля,** чтобы обмен и заказы писали в разные места. Тогда гонка исчезает по построению, без блокировок и без их цены.

**Блокировка лечит корректность за счёт отзывчивости.** На активном магазине это осознанный размен, а не бесплатное улучшение.

**Ускорение обмена — это ещё и точность остатков.** Связь неочевидная, но прямая: окно гонки равно длительности обмена.

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