Интерактивный архитектурный гид: дерево правил распределения, реализация принципов SOLID и GRASP в коде, паттерны Composite и Decorator, товарное соседство, Result Pattern и вопросы по лекциям кафедры.
По спецификации лабы вся логика распределения экземпляров строится в виде дерева через единый контракт IDistributionRule.
Мапит категорию товара на дочернее правило. Если маппинга нет — вызывает правило по умолчанию.
Содержит коллекцию правил вложенных тар. Перебирает их до первого успешного размещения товара.
Оборачивают любое правило, добавляя сквозную проверку срока годности или логирование исхода.
Базовое листовое правило. Делегирует непосредственное размещение сервису TareService.
Позволяет конфигурировать правила в зависимости от категории товара. Например, отправлять «Электронику» в тару А, «ФРОВ» в тару Б, а все остальные категории — в общее дефолтное правило.
public DistributionResult Distribute(Item item)
{
ArgumentNullException.ThrowIfNull(item);
if (_routes.TryGetValue(item.Sku.Category.Id, out IDistributionRule? rule))
{
return rule.Distribute(item);
}
return _defaultRule.Distribute(item);
}
Вынесено в отдельный сервис NeighborhoodValidator : INeighborhoodValidator по принципу единственной ответственности (SRP):
All(...) на пустой коллекции возвращает true).Ошибки бизнес-логики (нехватка вместимости, конфликт товарного соседства, просрочка) не бросают исключений, а возвращают полиморфный результат:
Исключения (ArgumentException) используются строго в конструкторах для валидации инвариантов инициализации.
Как реализовано в коде Лабы №2:
Tare — хранение состояния и вместимости ящика.NeighborhoodValidator — только валидация совместимости товарных групп.TareService — оркестрация операции добавления.ExpirationRuleDecorator — только проверка срока годности.
Дерево правил IDistributionRule позволяет добавлять новые правила (например, ограничение по хрупкости, температурному режиму или новый декоратор) без изменения классов PalletDistributionRule или TareDistributionRule.
В палету или маршрутизатор можно передать как простую тару TareDistributionRule, так и вложенную палету или обёрнутое в декоратор правило. Ни один потомок не нарушает контракт, не бросает неожиданных исключений и возвращает предсказуемый DistributionResult.
Вместо «толстого» интерфейса склада интерфейсы разделены по ролям: IDistributionRule (1 метод), INeighborhoodValidator (1 метод), ITimeProvider (1 метод), IRuleLogger (1 метод).
TareService зависит от интерфейса ITareRepository (а не от базы SQL). Декораторы зависят от интерфейсов ITimeProvider и IRuleLogger (а не от часов Windows или консоли). Конкретные реализации передаются снаружи через конструктор (Dependency Injection).
Tare.CurrentOvh считает вес сама, так как держит список своих элементов.ProductGroup.ContainsCategory проверяет вхождение, так как хранит свои категории.
Классы NeighborhoodValidator и TareService — чистая выдумка (инфраструктурные сервисы), предотвращающие превращение Tare в God Object.
TareDistributionRule выступает посредником (Indirection) между клиентом и сервисом тар, благодаря чему клиенты не зависят от деталей вызова AddItem.
Настройте параметры входящего товара и целевую конфигурацию правил, чтобы протестировать логику в реальном времени.
Ответы выверены по лекционным материалам и слайдам преподавателей ИТМО.
Оба паттерна структурно похожи: оборачивают целевой объект и реализуют тот же интерфейс. Но у них разные цели (Intent):
Это две взаимосвязанные метрики качества ООП-дизайна:
Связь с SOLID: нарушение SRP ведёт к Low Cohesion, а нарушение OCP/DIP — к High Coupling.
Принцип Information Expert гласит: ответственность должна быть там, где данные. Если следовать ему слепо, то в класс Tare пришлось бы засунуть: сохранение в SQL-базу, проверку пожарной безопасности, логирование и сетевые запросы.
Чтобы этого не произошло, мы применяем принцип Pure Fabrication (Чистая выдумка): создаём искусственные сервисы (NeighborhoodValidator, TareService, репозитории), сохраняя сплочённость сущности Tare высокой.
Компоновщик позволяет единообразно работать как с отдельным объектом (листом), так и с группой объектов (контейнером):
IDistributionRule с методом Distribute(Item).TareDistributionRule (правило одной тары).PalletDistributionRule (содержит список дочерних IDistributionRule и делегирует им обход).Они говорят об одном и том же с разных сторон: Protected Variations — это мотив («защитить систему от точек нестабильности с помощью стабильного интерфейса»), а OCP — это следствие этой защиты («код открыт для расширения новыми классами, но стабильный интерфейс закрыт от правок»).
Прямой вызов DateTime.UtcNow в доменном коде привязывает логику к физическим часам ОС. Мы не можем протестировать, как поведёт себя система через 3 дня, не заставляя тест спать 3 дня. Через интерфейс ITimeProvider мы передаём в тестах MockTimeProvider и мгновенно перемещаем виртуальное время вперёд или назад.
Команды для терминала, которые полностью эмулируют пайплайн проверки на GitHub.
За счёт включённого флага TreatWarningsAsErrors=true сборка мгновенно покажет любые придирки линтера.
Запускает все тесты второй лабораторной с цветным отчётом в консоли.
Все 21 юнит-тест проекта разбиты по 6 тестовым классам. Для каждого теста расписана бизнес-ситуация на складе, логика Arrange-Act-Assert, проверяемый инвариант и привязка к кейсам из методички.
Преподаватель в первую очередь проверяет покрытие именно этих 7 сценариев, явно зафиксированных в формулировке лабораторной:
| № ТЗ | Сценарий из задания | Тестовый метод | Файл теста | Ожидаемый результат |
|---|---|---|---|---|
| Кейс 1 | Товарное соседство: бумага, ножницы, камень не кладутся вместе | AddItem_NeighborhoodConflict_ShouldReturnNeighborhoodViolation | TareServiceTests.cs | NeighborhoodViolation |
| Кейс 2 | Превышение вместимости тары по ОВХ | AddItem_ExceedsCapacity_ShouldReturnCapacityExceeded | TareServiceTests.cs | CapacityExceeded |
| Кейс 3 | Роутер категорий без совпадения использует fallback-правило | Distribute_WithoutCategoryMapping_ShouldDelegateToDefaultRule | CategoryRouterRuleTests.cs | Success(DefaultTare) |
| Кейс 4 | Роутер с картой категорий направляет в нужные тары | Distribute_CategoryMapped_ShouldRouteToConfiguredRule | CategoryRouterRuleTests.cs | Success(TargetTare) |
| Кейс 5 | Товар без группы отклоняется при попытке положить к товару с группой | AddItem_ItemWithoutGroupToTareWithGroup_ShouldReturnNeighborhoodViolation | TareServiceTests.cs | NeighborhoodViolation |
| Кейс 6 | Декоратор логирования фиксирует успешные и неуспешные попытки | Distribute_SuccessAndFailureResults_ShouldLogBothOutcomes | LoggingRuleTests.cs | mockLogger.Count == 2 |
| Кейс 7 | Палета с тарами для свежих и просроченных товаров (фильтрация) | Distribute_ExpiredItemWithMultipleTareRules_ShouldAddToMatchingExpiredTare | PalletDistributionRuleTests.cs | Success(ExpiredTare) |
7 тестов • Бизнес-логика наполнения тары, вместимость по ОВХ и товарное соседство
Складской сценарий: В таре уже лежит товар из группы "Канцтовары" (ножницы). Кладовщик пытается доложить "Бумагу" (другая группа, например офисная бумага без совместимости).
Складской сценарий: В таре лежит блокнот (канцтовары). В ту же тару добавляется шариковая ручка из той же самой группы.
ProductGroup.Id == "office".Складской сценарий: Вместимость тары 50 ОВХ. В таре уже лежит товар на 45 ОВХ. Пытаемся засунуть товар на 10 ОВХ (45 + 10 = 55 > 50).
Складской сценарий: В таре лежит товар с группой (например, «Бытовая химия»). Пытаемся положить товар БЕЗ группы (безымянный/некатегоризованный).
Складской сценарий: Обратная ситуация: в таре уже лежит товар БЕЗ группы. Пытаемся добавить товар С группой.
Складской сценарий: Два товара без указания группы (например, нейтральные сувениры или гвозди) складываются в одну тару.
Складской сценарий: Запрос на добавление товара в несуществующую тару (ошиблись штрихкодом или ID ячейки).
3 теста • Маршрутизация по категориям и fallback на правило по умолчанию
Складской сценарий: Роутер инициализирован БЕЗ персональных маппингов (пустой словарь маршрутов), но с defaultRule на Тару C. Поступает свежая выпечка.
Складской сценарий: Задана карта маршрутов: электроника уходит в Тару A, а фрукты/овощи (FROV) — в Тару B.
Складской сценарий: Роутер настроен на электронику, но на конвейер приехала книга (категория unmapped). Роутер мягко перенаправляет ее в defaultRule.
4 теста • Паттерн Composite (палета), цепочка опроса тар и ленивая остановка
Складской сценарий: На палете стоят 2 тары: первая обернута в ExpirationDecorator (режим NotExpiredOrNone), вторая — в ExpirationDecorator (ExpiredOnly). Приходит просроченное молоко.
Складской сценарий: На ту же палету поступает свежее молоко. Оно должно сразу попасть в первую тару (для свежих), а вторая тара даже не должна опрашиваться.
Складской сценарий: Первая тара на палете забита до отказа (CapacityExceeded). Палета автоматически проверяет вторую тару и размещает товар там.
Складской сценарий: Абсолютно все тары на палете заполнены или несовместимы с товаром. Палета возвращает сводный Rejection.
4 теста • Декоратор фильтрации по срокам годности (NotExpiredOrNone vs ExpiredOnly)
Товар без срока годности (канцтовары, гвозди) успешно принимается правилом для свежих/бессрочных товаров.
Бессрочный товар нельзя положить в тару, предназначенную исключительно для просрочки (ExpiredOnly).
Просроченный йогурт не может быть помещен в обычную тару для свежих товаров.
Свежий продукт отклоняется правилом "Только просрочка" — защищает от случайного списания годного товара.
1 тест • Паттерн Декоратор и Mock-объект
Проверяет прозрачную работу декоратора: внутреннее правило вызывается без искажений, а каждое распределение (как успешное, так и отклоненное) аккуратно пишется в IRuleLogger.
MockRuleLogger собирает лог-записи в список.logger.CallCount == 2; зафиксированы обе записи с правильными тегами!2 теста (8 проверок) • Инварианты сущностей и Value Objects
Ovh_NegativeValue: отрицательные габариты бросают ArgumentOutOfRangeException.Tare_ZeroCapacity: емкость тары ≤ 0 бросает ArgumentException.InitialItemsExceedCapacity: нельзя создать тару, уже переполненную на старте.Sku_CategoryNotInProductGroup: SKU не может иметь категорию, не входящую в указанную товарную группу!Identity Equality: Сравнение Entity идет по Id, а Value Object (Ovh) — по значению.
В строгом объектно-ориентированном подходе и небольших тестовых наборах написание легковесного шпиона/мока на основе интерфейса IRuleLogger демонстрирует чистое понимание интерфейсной сегрегации (ISP) и инверсии зависимостей (DIP) без подключения тяжелых внешних reflection-библиотек.
Тесты на 100% детерминированы. Вместо обращения к системному DateTime.UtcNow используется интерфейс ITimeProvider. В тесте передается фиктивный провайдер с фиксированной датой (например, 2026-10-20), поэтому тесты никогда не упадут в будущем.
Потому что переполнение тары или несовместимость категорий — это штатный бизнес-сценарий работы склада, а не аварийная ситуация. Исключения зарезервированы только для нарушения инвариантов (баги программиста). Поэтому методы возвращают DistributionResult.
Arrange (подготовка тестовых данных и дерева правил), Act (вызов целевого метода Distribute() или AddItem()) и Assert (проверка типа возвращенного результата и состояния объектов через Xunit Assert.True/IsType).
Подробная сводка всех архитектурных и структурных улучшений, внесённых в проект: переход на Strongly-Typed IDs, разворот связей между сущностями, чистка лишних классов и стандартизация именования тестов.
Замена строк на readonly record struct (`TareId`, `ItemId`, `SkuId`, `CategoryId`, `ProductGroupId`). Никакого Primitive Obsession.
Устранена циклическая зависимость: группа владеет списком SkuId, а Sku больше ничего не знает о группе.
Все 21 тест переименованы по схеме Method_State_ShouldExpectedBehavior. Добавлена проверка на дубликаты.
Устранение антипаттерна «Primitive Obsession»
В чём была проблема: Раньше идентификаторы тар, товаров, категорий и SKU были обычными строками string id. При вызове метода вроде AddItem(string tareId, string itemId) компилятор позволял случайно поменять параметры местами, и ошибка выявлялась бы только в рантайме.
Что сделано: Созданы легковесные структуры в пространстве Entities.Identifiers:
readonly record struct живёт на стеке и не создаёт дополнительной нагрузки на Garbage Collector (GC), в отличие от классов.ItemId вместо TareId невозможно — проект просто не скомпилируется.Устранение круговой зависимости и централизация в TareService
Как было раньше: Каждый Sku напрямую хранил ссылку на ProductGroup. Это создавало циклическую привязку данных и усложняло валидацию: один и тот же товар не мог динамически участвовать в правилах склада без изменения своей сущности.
Как стало:
Sku хранит только свои свойства (Id, Name, Category, Ovh) и свободен от знания о группах.ProductGroup агрегирует коллекцию IReadOnlyCollection<SkuId> и предоставляет быстрый метод ContainsSku(SkuId).TareService при инициализации принимает каталог групп IEnumerable<ProductGroup> и выполняет сопоставление через метод FindProductGroupId(skuId).Удаление сущности Pallet, рефакторинг роутера и защита от дубликатов
В доменной модели был отдельный класс Pallet, дублирующий список тар. По ТЗ палета — это не сущность хранения, а композитное правило распределения (PalletDistributionRule). Лишний класс был удалён.
В TareAdditionResult выделен базовый тип Failure и добавлен исход ItemAlreadyExists. Тара проверяет tare.ContainsItem(item.Id), исключая случайное повторное добавление одного и того же физического экземпляра.
Метод ITareRepository.Save() переименован в AddOrUpdate(Tare tare). Это явно отражает семантику upsert и устраняет двусмысленность между добавлением новой тары и модификацией существующей.
Переход на общепринятый стандарт UnitOfWork_StateUnderTest_ExpectedBehavior
Все методы тестов переименованы с использованием конструкции _Should..., что делает отчеты XUnit в CI/CD легко читаемыми:
| Тестовый класс | Было (прежнее название) | Стало (новое стандартное название) |
|---|---|---|
| TareServiceTests | AddItem_NeighborhoodConflict_ShouldReturnNeighborhoodViolation | AddItem_NeighborhoodConflict_ShouldReturnNeighborhoodViolation |
| TareServiceTests | AddItem_ExceedsCapacity_ShouldReturnCapacityExceeded | AddItem_ExceedsCapacity_ShouldReturnCapacityExceeded |
| TareServiceTests | AddItem_NonExistentTare_ShouldReturnTareNotFound | AddItem_NonExistentTare_ShouldReturnTareNotFound |
| TareServiceTests | AddItem_SameProductGroup_ShouldSuccessfullyAdd | AddItem_SameProductGroup_ShouldSuccessfullyAdd |
| CategoryRouterRuleTests | Distribute_WithoutCategoryMapping_ShouldDelegateToDefaultRule | Distribute_WithoutCategoryMapping_ShouldDelegateToDefaultRule |
| CategoryRouterRuleTests | Distribute_CategoryMapped_ShouldRouteToConfiguredRule | Distribute_CategoryMapped_ShouldRouteToConfiguredRule |
| PalletDistributionRuleTests | Distribute_AllTaresFull_ShouldReturnNoRuleMatched | Distribute_AllTaresFull_ShouldReturnNoRuleMatched |
| LoggingRuleTests | Distribute_SuccessAndFailureResults_ShouldLogBothOutcomes | Distribute_SuccessAndFailureResults_ShouldLogBothOutcomes |
| EntityValidationTests | Ovh_NegativeValue_ThrowsArgumentOutOfRangeException | Constructor_NegativeOvhValue_ShouldThrowArgumentOutOfRangeException |
| EntityValidationTests | Tare_ZeroCapacity_ThrowsArgumentException | Constructor_ZeroTareCapacity_ShouldThrowArgumentException |