Skip to content

Saleor: товар сняли с продажи — и строку из корзины покупателя стирали в фоне. Три с половиной года до теста, поменявшего знак

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1

Как это должно было работать

Saleor, корзина (checkout), апрель 2021 — ноябрь 2024.

В Saleor товар не просто «есть» или «нет» — он выставлен в канале продаж: у товара в конкретном канале есть цена и признак доступности, а без этой записи товар в канале как бы не существует. Магазин может снять товар с канала одним запросом — и у покупателей, которые уже положили его в корзину, возникает вопрос, что теперь лежит у них в корзине.

История о том, как на этот вопрос отвечали трижды, каждый раз по-разному, и как последний ответ отменил самый первый — через три с половиной года.

Кадр первый: 26 апреля 2021. «Удалить строку, когда товар сняли»

Damian Wysocki (d-wysocki) открывает PR #7239 в ветку 3.0. Ветка называется fix_fetch_checkout_lines, а первый коммит — Remove checkoutline when variant/product was deleted. Ответ на вопрос из вступления — первый и самый простой: если товар сняли с канала, строки с ним из корзин удалить.

CheckoutLine.objects.filter(
variant__in=variants, checkout__channel__id__in=channel_id
).values("id", "checkout__pk")
CheckoutLine.objects.filter(id__in=lines_ids).delete()

По-человечески: найти во всех корзинах строки с этим товаром — и стереть. Покупателю об этом никто ничего не говорит; он откроет корзину и увидит, что она стала короче.

В ревью спорят, но не об этом. У корзины тогда было денормализованное поле quantity — общее число штук, хранимое отдельно от строк. Filip Owczarek (fowczarek) пять раз, в трёх файлах, пишет одно и то же — в том числе под самим удалением:

We also should update the quantity field on the Checkout model.

Надо ещё обновить поле quantity у корзины.

На следующий день Maciej Korycinski (korycins) возражает уже против самого обновления:

Can we figure out a different solution? This way has a big possibility to explode. We don't clean old checkout which it is easy to assume that we could have over 500k checkout objects. Assuming that 10% of this has the given variant assigned as a line will cause a huge amount of DB queries, update_checkout_quantity makes aggregation and save, so this action will cause around 100k queries. (Correct me if I am wrong)

Может, придумаем другое решение? У этого большие шансы взорваться. Старые корзины мы не чистим, так что легко допустить больше 500 тысяч корзин. Если у 10 % из них этот товар в строке, получится огромное число запросов — update_checkout_quantity делает агрегацию и сохранение, то есть около 100 тысяч запросов. (Поправьте, если ошибаюсь.)

d-wysocki отвечает через пятнадцать минут: «I will try to find more optimised solution» — попробую найти решение получше. Решение находится радикальное: поле quantity убирают из модели вовсе и считают на лету. PR так и переименован: Remove quantity field from checkout model.

Удаление строк при этом остаётся. Оно было поводом для всего спора — и единственным, что в споре не обсуждали. 5 мая 2021 PR вливают; в тот же день он переезжает в master коммитом afa1c1c (#7296, ревью Marcin Gębala, maarcingebala). Появляется и тест с говорящим именем: ..._remove_channel_removes_checkout_lines — «снятие с канала удаляет строки корзины». Теперь удаление охраняется.

Кадр второй: 25 мая 2023. Через два года — «не роняйте корзину»

Timur Carpeev (timuric) открывает issue #12938 — про соседнюю беду, но той же породы:

Many mutations like addressUpdate, addPromotCode fail when stock of one of the items is 0. … This behavior is blocking users from interacting with checkout and could lead to bugs in the storefront.

Многие операции — обновить адрес, применить промокод — падают, если у одного из товаров ноль на складе. … Это не даёт покупателю вообще работать с корзиной.

Товар кончился — и корзина перестаёт отвечать на что бы то ни было, включая попытку поменять адрес доставки. На следующий день korycins заводит issue #12943 с формулировкой, которая переворачивает подход:

Saleor doesn't have a way to provide non-failing feedback when something is wrong or some problems need to be handled by the customer.

У Saleor нет способа сообщить о проблеме, не падая, — когда что-то не так и разбираться с этим должен покупатель.

Через полтора месяца, 10 августа 2023, вливается его PR #13458 «Better checkout error feedback» (ревью fowczarek, kadewu, maarcingebala, yellowee). У корзины и у каждой её строки появляется поле problems, а в нём — в том числе такой тип (problems.py):

@dataclass
class CheckoutLineProblemVariantNotAvailable:
line: CheckoutLine

«Строка есть, товар недоступен». Корзина научилась говорить о проблеме словами, а не ошибкой. Но строку, у которой товар сняли с канала, до этого разговора по-прежнему не допускали — её удалял код 2021 года.

Кадр третий: 29 октября 2024. 'NoneType' object has no attribute 'price'

Ещё через год с небольшим korycins открывает PR #16949. В описании — крах с говорящим текстом: AttributeError: 'NoneType' object has no attribute 'price'. Причина:

Removing channel support via ProductVariantBulkUpdate, causes that we have checkouts with the lines that do not have enough details to list the prices. This causes unhandled exception returned by API.

Снятие с канала через ProductVariantBulkUpdate оставляет корзины со строками, у которых не хватает данных, чтобы показать цену. API отвечает необработанным исключением.

Удаление 2021 года стояло в одном месте — в мутации обновления листинга. А снять товар с канала можно и другой мутацией, массовой; там строки никто не удалял, они оставались, и при первом же показе корзины код лез за ценой в запись, которой больше нет. Три с половиной года путь в обход стоял открытым.

Решение — не добавить второе удаление, а перестать зависеть от листинга:

We will correctly return the lines without channel-listing. To do that we will use de-normalized Variant prices stored in CheckoutLine. … This blocks actions like finalizing checkout process when checkout contains invalid lines, user will need to make an action to remove the non-available lines.

Мы будем правильно возвращать строки без листинга — по денормализованной цене, хранящейся в самой строке. … Оформить такую корзину нельзя, покупателю придётся самому убрать недоступные строки.

В этом же PR исчезает вызов удаления из ветки «убрать варианты» — остаётся только в ветке «убрать канал целиком». Вливают 6 ноября 2024 коммитом 715b55e (ревью IKarbowiak, fowczarek, kadewu).

Кадр четвёртый: 18 ноября 2024. Тест меняет знак

Через двенадцать дней тот же korycins открывает PR #17027 «Do not delete checkout lines when product-channel-listing is removed» и объясняет, чем плохо то, что осталось:

I want to merge this change because it fixes the problem of deleting the existing CheckoutLines in the background when removing ProductChannelListing. This could impact on existing checkouts, as we could change the checkout content without informing the user.

Это чинит удаление существующих строк корзины в фоне при снятии товара с канала. Оно затрагивает живые корзины: мы меняли содержимое корзины, не сообщая покупателю.

Функция perform_checkout_lines_delete — та самая, из первого кадра — удаляется целиком. А в тестах происходит вещь, ради которой стоило дочитать:

-def test_product_channel_listing_update_remove_channel_removes_checkout_lines(
+def test_product_channel_listing_update_remove_channel_dont_remove_checkout_lines(
- assert not checkout.lines.all().exists()
+ assert checkout.lines.all().exists()

Тест 2021 года требовал, чтобы строк не осталось. Теперь он требует, чтобы они остались. Одно и то же действие администратора, одна и та же корзина — и утверждение, которое три с половиной года считалось правильным поведением, переписано в противоположное. Заодно уходит комментарий из fetch.py, где компромисс 2024 года был описан словами — «variant price is denormalized on checkout line object. We have enough information to include the variant without listing in the calculations» — потому что теперь это не оговорка для особого случая, а правило для всех строк.

Вливают 21 ноября 2024 коммитом 66f8801 (ревью kadewu, IKarbowiak, Anna Szczęch — szczecha) и переносят в ветки 3.19 и 3.20. От первого кадра до этого — три года, шесть месяцев и шестнадцать дней.

Кто в кадре

  • d-wysocki — Damian Wysocki, Saleor. Автор удаления строк (2021) и решения убрать поле quantity из модели после возражения в ревью.
  • fowczarek — Filip Owczarek, Saleor. В ревью 2021 года пять раз напомнил про денормализованное поле; в 2023 и 2024 — ревьюер обеих починок.
  • korycins — Maciej Korycinski, Saleor. В 2021 году возразил против 100 тысяч запросов; в 2023 сформулировал «сообщать, а не падать» и сделал problems; в 2024 сначала научил корзину показывать строку без листинга, потом убрал удаление, которое сам когда-то пропустил в ревью.
  • timuric — Timur Carpeev. Автор issue #12938 — слов о том, что корзина с кончившимся товаром перестаёт отвечать.
  • maarcingebala — Marcin Gębala. Ревьюер переноса 2021 года и починки 2023-го.
  • IKarbowiak — Iga Karbowiak, Saleor; kadewu; szczecha — Anna Szczęch, Saleor; yellowee — ревьюеры починок 2023–2024.

Цена решения

Строка с недоступным товаром теперь живёт в корзине и участвует во всех расчётах по цене, запомненной в самой строке, — так прямо написано в описании #17027: «all "invalid" lines, are present in the API response, and they are included in all calculations». Значит, витрине нельзя больше просто показать корзину: она обязана читать problems и рисовать строку иначе, иначе покупатель увидит сумму с товаром, который ему не продадут. Saleor эту цену принял сознательно — годом раньше useLegacyErrorFlow в настройках канала оставил старое поведение тем, кто ещё не готов.

Вторая цена — в самом первом кадре. Спор в ревью 2021 года был настоящим и полезным: возражение про 500 тысяч корзин убрало из модели целое поле. Но удаление, вокруг которого спорили, прошло без единого вопроса, потому что обсуждали как его сделать дешевле, а не надо ли его делать. Тест закрепил ответ, и следующие три года любой, кто прочитал бы removes_checkout_lines, решил бы, что так задумано.

У себя мы нашли не первый кадр, а второй, 2023 года: корзина в браузере хранила цену на момент добавления, и о снятом с продажи гость узнавал отказом после заполненной формы. Теперь каждая строка при показе сверяется с живым меню: недоступная остаётся с надписью и кнопкой «Убрать», а кнопка оформления выключена, пока такие строки есть. Ревью тут же нашло путь в обход, как в третьем кадре: сервер проверял правила групп добавок («ровно одна острота»), а сверка — только цены. В одном разошлись с Saleor намеренно: недоступная строка у нас в сумму не входит — число в итоге, которого нет ни в одной строке, обещало бы то, чего в заказе не будет.

Что с этим делать у себя

1
Не менять корзину покупателя молча

Товар сняли, цена изменилась, состав вариантов поменялся — строка остаётся, получает объяснение и кнопку «Убрать». Стирать её за покупателя нельзя: он не узнает, чего лишился.

Why: Ровно этой фразой — «we could change the checkout content without informing the user» — Saleor через три с половиной года отменил собственное решение.
Check
  • Ни один серверный или фоновый код не удаляет строки чужих корзин
  • У недоступной строки есть текст причины и действие для покупателя
2
Сообщать о проблеме, а не падать

Корзина с недоступной строкой обязана отвечать на всё остальное: смену адреса, промокод, счётчик количества. Ошибка допустима только на самом оформлении, и то — с названной причиной.

Why: В 2023 году у Saleor корзина с одним кончившимся товаром переставала отвечать вообще — включая попытку поменять адрес доставки.
Check
  • Показ и правка корзины не зависят от доступности отдельных строк
  • Оформление с проблемной строкой выключено, причина показана до нажатия
3
Сверять корзину по тем же правилам, по которым сервер её примет

Если сервер при оформлении проверяет не только цену, но и правила («ровно одна острота», «не больше трёх соусов»), витрина обязана проверять то же самое — тем же кодом или его зеркалом. Иначе покупатель узнает об отказе на последнем нажатии.

Why: У Saleor удаление стояло в одной мутации, а снять товар можно было и другой — три с половиной года путь в обход был открыт. У нас обход нашёлся в ревью с первого раза: сверка знала цены и не знала правил.
Check
  • Список того, что проверяет сервер при оформлении, выписан
  • Каждый пункт из списка либо проверяется на витрине, либо записано, почему нет
4
В ревью спросить «надо ли», прежде чем «как дешевле»Recommended

Когда обсуждение уходит в стоимость (запросы, индексы, поля), остановиться и спросить, нужно ли само действие. Самая дорогая правка в этой истории — та, которую не обсуждали.

Why: Спор 2021 года был настоящим и полезным — убрал из модели целое поле. Удаление строк, вокруг которого спорили, прошло без единого вопроса.
Check
  • В описании PR написано, зачем действие нужно покупателю, а не только как оно устроено
5
Читать имена тестов как утверждения о продуктеRecommended

removes_checkout_lines три года сообщал каждому читателю, что так задумано. Раз в год пройтись по именам тестов на границе с покупателем и спросить: мы всё ещё этого хотим?

Why: Тест закрепляет не правильность, а текущий ответ. Когда ответ меняется, тест меняет знак — и это видно только тому, кто прочитал имя.
Check
  • Найден хотя бы один тест, имя которого описывает поведение, а не вызов функции, и решено, верно ли оно до сих пор

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

v10Public 0 0
updated Sep 1, 2026

Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении

v2Public 0 0
updated Sep 1, 2026

Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег

v2Public 0 0
updated Sep 2, 2026

Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный

v2Public 0 0
updated Sep 2, 2026

Люди и проекты из разборов — по поступкам, датам и ссылкам. Как определитель птиц: не оценивает, а помогает узнать, кого встретил

v8Public 0 0
updated Sep 2, 2026

Карточка вида: чем живёт, как принимает чужаков, кто в нём работает. Наблюдения по коду, переписке и тому, что проект пишет о себе сам

v1Public 0 0
updated Sep 2, 2026