Дельта в API: как исключить повторное прибавление количества товара
На проверяемой модели сравниваем изменение количества товара и запись его абсолютного значения. Один operation_id не даёт повторно применить ту же команду, а проверка версии отклоняет старый снимок после нового движения товара.

В этой статье
Если команда означает «прибавить 3», сетевой повтор должен прийти с тем же operation_id, а получатель — вернуть прежний результат, не меняя количество единиц товара второй раз. Если команда означает «установить 8», точный повтор сам по себе безопаснее, но устаревший снимок способен затереть более новую продажу. Поэтому дельту и абсолютное значение нельзя смешивать в одном неявном поле.
На маленьком примере начальное количество товара равно 5 единицам. Первая поставка добавляет 3 единицы. Без защиты два одинаковых запроса дают 11. С журналом операций первый запрос фиксирует 8, а второй получает результат duplicate и оставляет 8 единиц товара. Сеть иногда повторяет просьбу — она не упрямая, ей просто не пришёл ответ.
Запрос к API | Точный повтор | Отдельный риск |
|---|---|---|
adjust +3 | Без operation_id снова прибавит 3 | Один документ поставки учтён дважды |
set 8 | Снова оставит 8 | Старый снимок может затереть более новое изменение |
Почему HTTP-метода недостаточно
В RFC 9110 идемпотентной названа операция, у которой несколько одинаковых запросов имеют тот же задуманный эффект, что и один. PUT относится к идемпотентным методам по определению, но бизнес-смысл поля всё равно важен: тело «delta: 3» не превращается в абсолютное количество единиц товара только из-за выбранного глагола.
RFC 5789 отдельно указывает, что PATCH не является идемпотентным сам по себе, хотя запрос можно спроектировать идемпотентным. Для изменения от известной версии спецификация рекомендует условный запрос; в HTTP для этого подходит, например, If-Match с сильным ETag.
Условия учебной проверки
Проверка выполнена 31 октября 2026 года в CPython 3.12. Модель работает только в памяти: не открывает сеть, не пишет файлы и не подключается к базе. Это проверка механизма, а не испытание конкретной редакции 1С-Битрикс или конфигурации 1С.
один SKU: SKU-BLUE-M; исходное количество товара — 5 единиц, версия 1;
adjust прибавляет дельту к количеству единиц товара, set задаёт абсолютное значение;
operation_id уникален для бизнес-операции, а не для сетевой попытки;
точный повтор возвращает сохранённый результат; тот же ID с другим телом отклоняется;
Получатель сравнивает expected_version запроса с текущей версией товара. Если версии различаются, он отклоняет устаревшую команду set и сохраняет текущее количество товара.
Код модели
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Update:
operation_id: str
sku: str
mode: str
value: int
expected_version: int | None = None
class Conflict(Exception):
pass
class Catalog:
def __init__(self) -> None:
self.stock = {"SKU-BLUE-M": 5}
self.version = {"SKU-BLUE-M": 1}
self.applied: dict[str, tuple[Update, tuple[str, int, int]]] = {}
def naive_adjust(self, sku: str, delta: int) -> int:
self.stock[sku] += delta
return self.stock[sku]
def apply(self, update: Update) -> tuple[str, int, int]:
previous = self.applied.get(update.operation_id)
if previous is not None:
previous_update, previous_result = previous
if previous_update != update:
raise Conflict("operation_id_reused_with_different_payload")
return ("duplicate", previous_result[1], previous_result[2])
if update.sku not in self.stock:
raise KeyError("unknown_sku")
current_version = self.version[update.sku]
if update.expected_version is not None and update.expected_version != current_version:
raise Conflict("version_conflict")
if update.mode == "adjust":
self.stock[update.sku] += update.value
elif update.mode == "set":
if update.value < 0:
raise ValueError("negative_stock")
self.stock[update.sku] = update.value
else:
raise ValueError("unknown_mode")
self.version[update.sku] += 1
result = ("applied", self.stock[update.sku], self.version[update.sku])
self.applied[update.operation_id] = (update, result)
return result
def show(label: str, result: object) -> None:
print(f"{label}: {result}")
naive = Catalog()
show("naive first +3", naive.naive_adjust("SKU-BLUE-M", 3))
show("naive retry +3", naive.naive_adjust("SKU-BLUE-M", 3))
assert naive.stock["SKU-BLUE-M"] == 11
safe = Catalog()
delta = Update("receipt-42", "SKU-BLUE-M", "adjust", 3)
show("safe first +3", safe.apply(delta))
show("safe retry +3", safe.apply(delta))
assert safe.stock["SKU-BLUE-M"] == 8
assert safe.version["SKU-BLUE-M"] == 2
try:
safe.apply(Update("receipt-42", "SKU-BLUE-M", "adjust", 4))
except Conflict as error:
show("same id, changed payload", error)
else:
raise AssertionError("changed payload must be rejected")
# A later sale changes the authoritative value after the receipt was applied.
safe.stock["SKU-BLUE-M"] -= 1
safe.version["SKU-BLUE-M"] += 1
show("after later sale", (safe.stock["SKU-BLUE-M"], safe.version["SKU-BLUE-M"]))
try:
safe.apply(Update("snapshot-43", "SKU-BLUE-M", "set", 6, expected_version=2))
except Conflict as error:
show("stale absolute value", error)
else:
raise AssertionError("stale absolute value must be rejected")
snapshot = Update("snapshot-44", "SKU-BLUE-M", "set", 6, expected_version=3)
show("current absolute value", safe.apply(snapshot))
show("current absolute retry", safe.apply(snapshot))
assert safe.stock["SKU-BLUE-M"] == 6
assert safe.version["SKU-BLUE-M"] == 4
Результат запуска
naive first +3: 8
naive retry +3: 11
safe first +3: ('applied', 8, 2)
safe retry +3: ('duplicate', 8, 2)
same id, changed payload: operation_id_reused_with_different_payload
after later sale: (7, 3)
stale absolute value: version_conflict
current absolute value: ('applied', 6, 4)
current absolute retry: ('duplicate', 6, 4)
Проверка | Ожидалось | Получено |
|---|---|---|
Наивный +3 дважды | Показать ошибку повтора | 11 вместо 8 |
Тот же operation_id и тело | Не менять количество единиц товара | duplicate, 8, версия 2 |
Тот же ID, другая дельта | Отклонить конфликт | operation_id_reused_with_different_payload |
Устаревший set 6 | Не затереть продажу | version_conflict, осталось 7 |
Актуальный set 6 и повтор | Применить один раз | 6, версия 4; затем duplicate |
Все утверждения завершились успешно, процесс вернул код 0. Значимый крайний случай здесь не декоративный: после поставки модель списывает одну единицу продажей, поднимает версию с 2 до 3 и только затем получает старый снимок. Без проверки версии такой set мог бы молча переписать более новое состояние.
Как оформить контракт интеграции
Зафиксируйте направление: 1С → сайт, сайт → 1С или двусторонний обмен. Для каждого поля должен быть один источник окончательного значения в конкретной операции.
Передавайте стабильный идентификатор товара или торгового предложения. SKU-BLUE-M в модели — учебная строка; в проекте нужно проверить фактическое поле, вариант товара и правила сопоставления.
Разделите команды явно: adjust с дельтой и set с абсолютным значением. Поле quantity без режима заставляет получателя угадывать, а угадывание плохо масштабируется вместе с ассортиментом.
Генерируйте operation_id на уровне документа поставки, корректировки или другого бизнес-события. Повторная доставка того же события сохраняет ID; новая операция получает новый.
Храните вместе с ID отпечаток значимых полей и результат. Совпавший ID с другим телом — конфликт. Применять новую дельту в этом случае нельзя.
Изменение количества товара и запись operation_id должны быть атомарными. Иначе сбой между двумя записями оставит окно: количество товара уже изменено, а доказательства обработки ещё нет.
Для set передавайте ожидаемую версию или используйте условие If-Match, если API поддерживает ETag. После конфликта нужно перечитать состояние и принять решение, а не повторять старый снимок вслепую.
До выбора режима полезно отдельно определить источник окончательного остатка. Этот вопрос разобран в статье «Остаток в 1С и на сайте не совпадает: где должна жить окончательная цифра». Здесь мы решаем более узкую задачу: как не применить одну уже выбранную команду дважды.
Тут есть подвох: срок хранения ключей
Если удалить operation_id раньше, чем исчезнут повторы из очереди, старый запрос снова станет новым. Универсального срока нет: он должен быть длиннее максимального возраста сообщения, окна повторов клиента и ручного возврата из очереди. Срок, очистку и объём журнала стоит включить в контракт интеграции, а не оставлять настройкой «потом разберёмся».
Журнал операций также не заменяет проверку прав. Технический пользователь должен иметь доступ только к нужному каталогу, складу и операции. Названия сущностей, доступность API и модель прав зависят от редакции и версии 1С-Битрикс, модуля магазина и конкретного способа обмена — их нужно сверить по проекту до реализации.
Критерии приёмки
Первый adjust +3 переводит остаток 5 → 8 и сохраняет operation_id с результатом.
Точный сетевой повтор возвращает тот же бизнес-результат и не создаёт второе движение.
Повтор с тем же ID, но другой дельтой отклоняется как конфликт и попадает в наблюдаемую диагностику.
После нового движения устаревший set не перезаписывает остаток; клиент получает конфликт версии.
Неизвестный SKU, недостаточные права и отказ хранилища дают явную ошибку, а не частично применённую операцию.
Метрики различают applied, duplicate и conflict, чтобы безопасные повторы не маскировали реальные расхождения.
Для приёмки конкретной интеграции повторите эти сценарии на изолированном стенде с фактической редакцией 1С-Битрикс, модулем, направлением обмена, складами, торговыми предложениями и правами технического пользователя. Учебная модель доказывает поведение алгоритма, но не притворяется внедрением у клиента — у неё, к счастью, нет отдела закупок.
If a command means 'add 3,' a network retry must arrive with the same operation_id, and the receiver must return the original result without changing the product unit count a second time. If a command means 'set to 8,' an exact retry is inherently safer, but a stale snapshot can overwrite a newer sale. Therefore, delta and absolute values must not be mixed in a single implicit field.
In a small example, the initial product quantity is 5 units. The first shipment adds 3 units. Without protection, two identical requests yield 11. With an operation log, the first request records 8, and the second receives a duplicate result, leaving the quantity at 8 units. The network sometimes repeats a request—not out of stubbornness, but simply because it did not receive a response.
API Request | Exact Repetition | Separate Risk |
|---|---|---|
adjust +3 | Without operation_id, it adds 3 again | One delivery document counted twice |
set 8 | Again sets 8 | An old snapshot may overwrite a newer change |
Why HTTP Methods Are Insufficient
According to RFC 9110, an operation is called idempotent if multiple identical requests have the same intended effect as a single one. PUT is defined as an idempotent method, but the business meaning of the field remains important: the body "delta: 3" does not become an absolute quantity of product units solely because of the chosen verb.
RFC 5789 explicitly states that PATCH is not idempotent by itself, although a request can be designed to be idempotent. For changes from a known version, the specification recommends a conditional request; for HTTP, this can be done using If-Match with a strong ETag.
Conditions for the test acceptance
Check performed on October 31, 2026, in CPython 3.12. The model operates solely in memory: it does not open network connections, write files, or connect to a database. This is a test of the mechanism, not an evaluation of a specific 1C-Bitrix edition or 1C configuration.
one SKU: SKU-BLUE-M; initial product quantity is 5 units, version 1;
adjust adds the delta to the product quantity, set assigns an absolute value;
operation_id is unique for the business operation, not for the network attempt;
A precise match returns the saved result; the same ID with a different body is rejected;
The receiver compares the request's expected_version with the current product version. If the versions differ, it rejects the stale set command and retains the current product quantity.
Model code
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Update:
operation_id: str
sku: str
mode: str
value: int
expected_version: int | None = None
class Conflict(Exception):
pass
class Catalog:
def __init__(self) -> None:
self.stock = {"SKU-BLUE-M": 5}
self.version = {"SKU-BLUE-M": 1}
self.applied: dict[str, tuple[Update, tuple[str, int, int]]] = {}
def naive_adjust(self, sku: str, delta: int) -> int:
self.stock[sku] += delta
return self.stock[sku]
def apply(self, update: Update) -> tuple[str, int, int]:
previous = self.applied.get(update.operation_id)
if previous is not None:
previous_update, previous_result = previous
if previous_update != update:
raise Conflict("operation_id_reused_with_different_payload")
return ("duplicate", previous_result[1], previous_result[2])
if update.sku not in self.stock:
raise KeyError("unknown_sku")
current_version = self.version[update.sku]
if update.expected_version is not None and update.expected_version != current_version:
raise Conflict("version_conflict")
if update.mode == "adjust":
self.stock[update.sku] += update.value
elif update.mode == "set":
if update.value < 0:
raise ValueError("negative_stock")
self.stock[update.sku] = update.value
else:
raise ValueError("unknown_mode")
self.version[update.sku] += 1
result = ("applied", self.stock[update.sku], self.version[update.sku])
self.applied[update.operation_id] = (update, result)
return result
def show(label: str, result: object) -> None:
print(f"{label}: {result}")
naive = Catalog()
show("naive first +3", naive.naive_adjust("SKU-BLUE-M", 3))
show("naive retry +3", naive.naive_adjust("SKU-BLUE-M", 3))
assert naive.stock["SKU-BLUE-M"] == 11
safe = Catalog()
delta = Update("receipt-42", "SKU-BLUE-M", "adjust", 3)
show("safe first +3", safe.apply(delta))
show("safe retry +3", safe.apply(delta))
assert safe.stock["SKU-BLUE-M"] == 8
assert safe.version["SKU-BLUE-M"] == 2
try:
safe.apply(Update("receipt-42", "SKU-BLUE-M", "adjust", 4))
except Conflict as error:
show("same id, changed payload", error)
else:
raise AssertionError("changed payload must be rejected")
# A later sale changes the authoritative value after the receipt was applied.
safe.stock["SKU-BLUE-M"] -= 1
safe.version["SKU-BLUE-M"] += 1
show("after later sale", (safe.stock["SKU-BLUE-M"], safe.version["SKU-BLUE-M"]))
try:
safe.apply(Update("snapshot-43", "SKU-BLUE-M", "set", 6, expected_version=2))
except Conflict as error:
show("stale absolute value", error)
else:
raise AssertionError("stale absolute value must be rejected")
snapshot = Update("snapshot-44", "SKU-BLUE-M", "set", 6, expected_version=3)
show("current absolute value", safe.apply(snapshot))
show("current absolute retry", safe.apply(snapshot))
assert safe.stock["SKU-BLUE-M"] == 6
assert safe.version["SKU-BLUE-M"] == 4
Run result
naive first +3: 8
naive retry +3: 11
safe first +3: ('applied', 8, 2)
safe retry +3: ('duplicate', 8, 2)
same id, changed payload: operation_id_reused_with_different_payload
after later sale: (7, 3)
stale absolute value: version_conflict
current absolute value: ('applied', 6, 4)
current absolute retry: ('duplicate', 6, 4)
Check | Expected | Received |
|---|---|---|
Naive +3 twice | Show repeat error | 11 instead of 8 |
Same operation_id and body | Do not change the quantity of items | duplicate, 8, version 2 |
Same ID, different delta | Reject conflict | operation_id_reused_with_different_payload |
Outdated set 6 | Do not overwrite the sale | version_conflict, 7 remaining |
Current set 6 and retry | Apply once | 6, version 4; then duplicate |
All assertions passed; the process returned exit code 0. This significant edge case is not cosmetic: after delivery, the model decrements one unit by sale, increments the version from 2 to 3, and only then retrieves the old snapshot. Without a version check, such a set could silently overwrite a newer state.
How to structure the integration contract
Fix the direction: 1C → site, site → 1C, or two-way sync. For each field, there must be a single source of truth for the final value in a specific operation.
Transmit a stable product or product variant identifier. SKU-BLUE-M in the model is a sample string; in a project, verify the actual field, product variant, and matching rules.
Clearly separate commands: adjust with a delta and set with an absolute value. A quantity field without a mode forces the receiver to guess, and guessing does not scale well with assortment.
Generate operation_id at the level of a delivery document, adjustment, or other business event. Re-delivering the same event preserves the ID; a new operation receives a new one.
Store the fingerprint of significant fields and the result together with the ID. An ID match with a different body indicates a conflict. Applying a new delta in this case is not allowed.
Changing the product quantity and recording the operation_id must be atomic. Otherwise, a failure between the two writes leaves a window: the product quantity is already changed, but there is no proof of processing yet.
For set, pass the expected version or use the If-Match condition if the API supports ETag. After a conflict, you must re-read the state and make a decision, not blindly repeat the old snapshot.
Before selecting a mode, it is useful to separately define the source of the final stock. This issue is discussed in the article «Stock in 1C and on the site do not match: where the final number should live». Here we solve a narrower task: how to avoid applying one already selected command twice.
Here lies a catch: the key retention period
If operation_id is deleted before duplicates disappear from the queue, the old request becomes new again. There is no universal retention period: it must exceed the maximum message age, the client's duplicate window, and the manual return window from the queue. Retention period, cleanup logic, and journal volume should be included in the integration contract, not left as a "we will figure it out later" setting.
The operations journal does not replace permission checks. A technical user must have access only to the required catalog, warehouse, and operation. Entity names, API availability, and the permission model depend on the 1C-Bitrix edition and version, the shop module, and the specific exchange method; these must be verified against the project before implementation.
Acceptance Criteria
The first adjust +3 changes the stock from 5 to 8 and saves the operation_id with the result.
An exact network retry returns the same business result and does not create a second movement.
A retry with the same ID but a different delta is rejected as a conflict and enters observable diagnostics.
After a new movement, the outdated set does not overwrite the stock; the client receives a version conflict.
An unknown SKU, insufficient permissions, and a storage rejection produce an explicit error rather than a partially applied operation.
Metrics distinguish between applied, duplicate, and conflict states so that safe retries do not mask actual discrepancies.
To perform acceptance testing for a specific integration, repeat these scenarios on an isolated staging environment with the actual 1C-Bitrix edition, the relevant module, exchange direction, warehouses, product variants, and technical user permissions. The training model demonstrates algorithm behavior but does not simulate a client deployment — fortunately, it has no procurement department.





Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.