Практика: НДС и чек по заказу (Order visitors)
Заказ из товаров, скидок, доставки и упаковки, а над ним растёт список операций: НДС, фискальный чек, проверка. Операции живут в посетителях с состоянием, узлы заказа остаются данными.
Базовый пример: Посетитель. Там каждая операция отвечает на вопрос об одной фигуре и сразу возвращает ответ. В заказе операции нужны сведения сразу о нескольких узлах (скидка уменьшает строку, которая была раньше), а главный вопрос практики — во что обходится приём, когда в заказе появляется новый вид узла.
Задача
Интернет-магазин продуктов собирает заказ из узлов: товарные строки с разными ставками НДС (молоко 10%, кофе 20%), скидки по промокоду на конкретный товар, доставка, подарочная упаковка. По заказу нужно посчитать НДС по ставкам для бухгалтерии, отдать строки в онлайн-кассу и проверить заказ перед оплатой. Каждый квартал приходит ещё одна операция: лист сборки для склада, расчёт веса для курьера, выгрузка в аналитику.
Первая версия держала всё в методах узлов: Line::vat(), Line::receiptLine(), Line::validate(), и то же в скидке, доставке и упаковке. Четыре класса знали о бухгалтерии, кассе и складе одновременно, а чтобы скидка уменьшила НДС строки, ей пришлось хранить ссылку на строку. Новая операция означала правку всех четырёх классов и всех их тестов.
Что урезано
- Деньги — целые копейки одной валюты; ставки НДС — целые проценты, НДС включён в цену.
- НДС считается по ставке в целом (сумма по ставке, затем округление), а не по каждой позиции, как требует настоящий фискальный чек.
- Доставка и упаковка всегда по ставке 20%, у них нет признака предмета расчёта и способа оплаты.
- Скидка — фиксированная сумма на одну строку по артикулу; процентов и скидок на весь заказ нет.
- Заказ собирается в коде теста; в проекте он приходит из корзины и БД.
Решение
Контракт — тот же, что в базовом примере: узел OrderNode с одним методом accept, посетитель OrderVisitor с методом на каждый вид узла. Отличие — посетители здесь ничего не возвращают из visit*: каждый копит своё состояние по ходу обхода и отдаёт результат отдельным методом (byRate(), lines(), errors()). Так посетитель видит весь заказ, а не один узел.
Структура
Узлы — неизменяемые данные. Discount ссылается на строку артикулом, а не объектом: так узлы остаются простыми значениями, а связывать их — забота посетителя.
Обход заказа
Order — структура объектов из классического описания приёма: она хранит узлы и ведёт посетителя по ним в том порядке, в каком они добавлены. Порядок важен: скидка идёт после своей строки, и посетители на это рассчитывают. Обход в одном месте — посетителям не нужно знать, как заказ устроен внутри.
НДС по ставкам
VatBreakdown складывает суммы по ставкам: строка добавляет цена x количество к своей ставке и запоминает ставку артикула, скидка вычитает свою сумму из ставки той строки, на которую она дана, доставка и упаковка идут по 20%. НДС выделяется из суммы с округлением половины копейки вверх: по 20% из 1249,00 — 208,17. Посетитель рассчитывает на проверенный заказ: скидка на неизвестный артикул здесь не ищется.
Строки чека
Receipt готовит строки для онлайн-кассы. В фискальном чеке нет отрицательных позиций, поэтому скидка не печатается отдельной строкой, а уменьшает сумму своей позиции: кофе за 890,00 со скидкой 90,00 уходит в кассу как одна строка на 800,00. Для этого посетитель держит позиции по артикулу и правит уже добавленную.
Проверка заказа
OrderCheck не бросает исключение на первой ошибке, а собирает все: покупателю удобнее увидеть сразу весь список. Проверки межузловые: количество больше нуля, скидка только на строку, которая уже есть, и не больше её остатка, доставка одна. Упаковка ничего не проверяет, но метод для неё всё равно есть — интерфейс требует.
Как это собирается
Заказ собирает оформление из корзины: товары берутся из каталога, промокод превращается в Discount по таблице промокодов, доставка добавляется последней. Узлы ничего не считают, это просто данные в нужном порядке.
Дальше по одному заказу проходят три посетителя, и порядок задаёт вызывающий код. Сначала OrderCheck: VatBreakdown и Receipt рассчитывают на проверенный заказ, поэтому без проверки скидка на отсутствующий товар уронила бы расчёт. Во второй корзине кофе нет, а промокод на него есть: оформление показывает покупателю ошибку и не идёт к оплате. Первая корзина проходит, Receipt отдаёт строки в онлайн-кассу (скидка уже внутри строки кофе), а VatBreakdown - суммы НДС для бухгалтерии.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | interface OrderVisitor; посетитель без одного метода — фатальная ошибка при загрузке класса, тест проверяет её в отдельном процессе |
| Go | Посетители — указатели с состоянием, var _ OrderVisitor = (*Receipt)(nil) проверяет полноту при сборке; тест запускает go/types над исходником |
| Python | OrderVisitor(ABC) с @abstractmethod: без проверки типов в тулчейне только ABC не даёт создать неполного посетителя |
| TypeScript | Посетитель — любой объект с четырьмя методами, даже литерал; неполный не проходит tsc |
Запуск: make test T=patterns/behavioral/visitor/practice
Что проверяют тесты
Тесты одинаковы во всех языках и работают с одним заказом: кофе 890,00 (20%), два молока по 99,00 (10%), скидка SPRING 90,00 на кофе, доставка 299,00, упаковка 150,00.
- НДС:
{10: 1800, 20: 20817}. По 10% — 198,00 / 110 x 10 = 18,00 ровно; по 20% — (800 + 299 + 150) / 120 x 20 = 208,1666 -> 208,17. Скидка уменьшила базу 20%, а не 10%. - Чек: четыре строки, скидки среди них нет, кофе уже на 800,00.
- Проверка: таблица из правильного заказа (ошибок нет) и четырёх сломанных — нулевое количество, скидка на товар, которого нет, скидка больше строки, вторая доставка — с точным текстом ошибки.
- Новая операция: лист сборки для склада объявлен прямо в тесте и обходит тот же заказ. Ни узлы, ни
Orderдля этого не менялись. - Цена приёма: посетитель без
visitGiftWrapне принимается. PHP падает при объявлении класса (тест запускаетphp -rи ищетOrderVisitor::visitGiftWrapв выводе), Go не проходит проверку типов (missing method VisitGiftWrap), Python не создаёт объект (TypeErrorот ABC), TypeScript не компилируется (@ts-expect-error), а во время выполнения падает сTypeErrorна упаковке. Ровно это случится со всеми тремя посетителями, когда в заказ добавят новый вид узла, например сертификат.
Где граница
- Новые виды узлов появляются чаще новых операций (сертификаты, бонусы, услуги сборки каждый месяц) — каждый узел правит всех посетителей. Тогда операции возвращают в узлы, а общее выносят в базовый класс или стратегии.
- Расчёт НДС и чек по-настоящему — по каждой позиции, с разнесением скидки на заказ по строкам пропорционально и признаками расчёта. Посетитель остаётся, но его состояние вырастает в отдельную модель чека.
- Заказ приходит JSON-ом из корзины: тогда узлы — теговое объединение (
type: "line" | "discount"), и в TypeScript или Python с проверкой типовswitch/matchс проверкой полноты проще, чемaccept. - Операция одна (только чек) — посетитель избыточен, хватит функции, которая проходит по списку.
Отличия между языками
- PHP — неполный посетитель не загружается вовсе: «Class ... contains 1 abstract method». Это ошибка компиляции файла, её не поймать в
try, поэтому тест запускает отдельный процесс. Тип результата у каждого посетителя свой (byRate(): array<int, int>), обобщения не нужны. - Go — проверка
var _ OrderVisitor = ...стоит рядом с посетителями и ломает сборку пакета. Тест делает то же через стандартныеgo/parserиgo/types: подкладывает к исходнику неполного посетителя и ждёт ошибку типов. Картаmap[int]intсравнивается черезmaps.Equal, порядок ставок не важен. - Python —
Protocolпроверяет только mypy/pyright, которых в тулчейне нет;ABCпроверяет при создании объекта. Состояние посетителя — словари, позиция чека — изменяемый dataclass. - TypeScript — лист сборки в тесте — объектный литерал, класс не нужен. Неполный литерал
tscотвергает, но vitest типы не проверяет, поэтому тест показывает и то, что будет без компилятора:visitGiftWrap is not a function.
Связанные темы
- Посетитель (базовый пример)
- Практика Компоновщика: цена корзины с наборами — дерево позиций, где операция одна и живёт в узлах; когда операций станет много, их выносят в посетителей