handbookНаписать

patterns/behavioral/visitor/practice/README.md · практика · 4 языка · 39 тестов

база: Посетитель

Практика: НДС и чек по заказу (Order visitors) ​

Заказ из товаров, скидок, доставки и упаковки, а над ним растёт список операций: НДС, фискальный чек, проверка. Операции живут в посетителях с состоянием, узлы заказа остаются данными.

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

Задача ​

Интернет-магазин продуктов собирает заказ из узлов: товарные строки с разными ставками НДС (молоко 10%, кофе 20%), скидки по промокоду на конкретный товар, доставка, подарочная упаковка. По заказу нужно посчитать НДС по ставкам для бухгалтерии, отдать строки в онлайн-кассу и проверить заказ перед оплатой. Каждый квартал приходит ещё одна операция: лист сборки для склада, расчёт веса для курьера, выгрузка в аналитику.

Первая версия держала всё в методах узлов: Line::vat(), Line::receiptLine(), Line::validate(), и то же в скидке, доставке и упаковке. Четыре класса знали о бухгалтерии, кассе и складе одновременно, а чтобы скидка уменьшила НДС строки, ей пришлось хранить ссылку на строку. Новая операция означала правку всех четырёх классов и всех их тестов.

Что урезано ​

  • Деньги — целые копейки одной валюты; ставки НДС — целые проценты, НДС включён в цену.
  • НДС считается по ставке в целом (сумма по ставке, затем округление), а не по каждой позиции, как требует настоящий фискальный чек.
  • Доставка и упаковка всегда по ставке 20%, у них нет признака предмета расчёта и способа оплаты.
  • Скидка — фиксированная сумма на одну строку по артикулу; процентов и скидок на весь заказ нет.
  • Заказ собирается в коде теста; в проекте он приходит из корзины и БД.

Решение ​

Контракт — тот же, что в базовом примере: узел OrderNode с одним методом accept, посетитель OrderVisitor с методом на каждый вид узла. Отличие — посетители здесь ничего не возвращают из visit*: каждый копит своё состояние по ходу обхода и отдаёт результат отдельным методом (byRate(), lines(), errors()). Так посетитель видит весь заказ, а не один узел.

Структура ​

nodes

«interface»

OrderVisitor

+visitLine(line)

+visitDiscount(discount)

+visitDelivery(delivery)

+visitGiftWrap(wrap)

«interface»

OrderNode

+accept(visitor)

Line

+sku, name

+unitCents, qty, vatRate

Discount

+code, sku, cents

Delivery

+cents

GiftWrap

+cents

Order

-nodes: OrderNode[]

+accept(visitor)

VatBreakdown

+byRate() : map

Receipt

+lines() : string[]

OrderCheck

+errors() : string[]

Узлы — неизменяемые данные. 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 в панели кода.

Реализации ​

ЯзыкОсобенность
PHPinterface OrderVisitor; посетитель без одного метода — фатальная ошибка при загрузке класса, тест проверяет её в отдельном процессе
GoПосетители — указатели с состоянием, var _ OrderVisitor = (*Receipt)(nil) проверяет полноту при сборке; тест запускает go/types над исходником
PythonOrderVisitor(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.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS