Практика: КП из шаблона (Quote from template)
Копия коммерческого предложения для нового клиента: сбросить идентичность, продублировать строки, оставить общими товары каталога. Глубина копии — бизнес-решение, а не техническая деталь.
Базовый пример: Прототип. Там копия «просто глубокая». В жизни у объекта есть поля трёх сортов: те, что копировать нельзя (id, статус, даты), те, что копировать обязательно (строки с количеством и скидкой), и те, что копировать вредно (справочники, которые и так общие).
Задача
Менеджер по продажам делает коммерческое предложение (КП) новому клиенту «как прошлое для Acme, только поменять количество». КП состоит из строк: товар из каталога, количество, скидка; плюс условия оплаты. У принятого КП есть номер, статус и дата.
Первая версия копировала КП через сериализацию. Копия приходила с тем же номером и статусом accepted, менеджер забывал их сбросить, и в отчёте появлялись два принятых КП с одним номером. Потом сделали «глубокую копию всего» — и каждое КП таскало за собой копию карточек товаров, которые потом разошлись с каталогом по цене.
Что урезано
- Нет хранилища и генерации номеров: новый номер и сегодняшняя дата передаются параметрами.
- Каталог — два товара, созданные в тесте.
- Статусы — строки, без переходов.
- Деньги — целые копейки, скидка целыми процентами.
Решение
Части намеренно разные: Product неизменяемый и общий, QuoteLine изменяемая и личная для каждого КП.
Quote сам знает своё устройство. Копия дублирует строки, но не товары внутри них. А cloneFor() поверх копии делает то, что сериализация забывала: новый номер, нового клиента, статус draft, сегодняшнюю дату. Условия оплаты и строки переезжают как есть.
QuoteTemplates — «сохранить как шаблон» и «новое КП из шаблона». Шаблон замораживается в момент сохранения: дальнейшие правки исходного КП его не трогают, и каждое новое КП независимо от предыдущих.
Структура
Три сорта полей видны на схеме: Quote владеет строками (копировать), строка ссылается на товар (делить), а поля идентичности Quote сбрасываются в cloneFor().
Как это собирается
Шаблон рождается из реального КП: принятое предложение для Acme загружено из базы, товары в нём из каталога, и менеджер жмёт "сохранить как шаблон" - QuoteTemplates::save() замораживает копию. Новое КП для клиента собирает обработчик "КП из шаблона": номер берётся из последовательности, дата из часов, а newQuote() отдаёт черновик со своими строками, который менеджер правит дальше.
В выводе видно, что строки у каждого КП свои: Globex урезал количество ноутбуков, Initech получил скидку на мышей, а исходное КП Acme осталось принятым и с прежней суммой. Шаблон с несуществующим именем обработчик превращает в сообщение "шаблон не найден", черновик не создаётся.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Реестр копирует при сохранении, поэтому «сохранить как шаблон» — это снимок, а не ссылка на живое КП. Это отличие от многих CRM, где шаблон «плывёт» вместе с исходным документом и удивляет менеджеров.
| Язык | Особенность |
|---|---|
| PHP | __clone() дублирует строки; Product — readonly, его можно делить без риска |
| Go | Строки — значения в срезе, slices.Clone их копирует; товар — указатель, остаётся общим |
| Python | Явный replace по строкам вместо deepcopy: deepcopy размножил бы каталог |
| TypeScript | Явный clone() со спредом строк; Product — readonly интерфейс |
Запуск: make test T=patterns/creational/prototype/practice
Что проверяют тесты
Тесты одинаковы во всех языках. Копия получает новый номер, клиента, статус draft и дату, но те же условия и ту же сумму. Второй тест — главный: изменение количества в копии не трогает шаблон, а товар в строке — тот же самый объект. Третий — шаблон заморожен в момент сохранения. Четвёртый — два КП из одного шаблона не видят друг друга.
Где граница
- Граф объектов с циклами и общими узлами — ручной
clone()на каждом уровне становится хрупким; тогда либоdeepcopyс явным списком того, что делить (__deepcopy__с memo), либо отказ от копирования в пользу неизменяемых структур. - Копия нужна ради «отменить изменения» — это Снимок (Memento), а не прототип.
- Копируется только ради нескольких других значений — хватит
with…/replaceбез реестра. - Если «шаблон» должен меняться вместе с источником, прототип не подходит: это ссылка, а не копия, и это надо назвать явно.
Отличия между языками
- PHP —
cloneповерхностный, поэтому__clone()обязателен для строк.readonlyуProductделает общий доступ безопасным на уровне языка. - Go — глубина копии видна по типам: срез значений копируется
slices.Clone, указатель делится. Это удобно читать:*Productв структуре строки сам говорит «общий». - Python —
copy.deepcopyздесь был бы ошибкой: он скопировал бы иProduct. Явныйreplaceпо строкам короче__deepcopy__и честнее. - TypeScript —
structuredCloneпотерял бы классQuoteи размножил бы товары; спред по строкам делает ровно то, что нужно.
Связанные темы
- Прототип (базовый пример)
- Практика Строителя: запрос отчёта — неизменяемость вместо копирования, когда это возможно