Skip to main content

Вариант 1

1. Контекст и базовая формализация

1.1. Временной горизонт

Смена — фиксированный интервал длиной около 12 часов, работающей логистики жидкого цемента в светлое время суток.
Внутри смены у нас есть:

  • Интервал смены:

    • Атрибуты: начало смены, конец смены.
    • Все заказы и поставки смены должны быть полностью вписаны в этот интервал (нет поставок за его пределами).
  • Временная линия Ганта:

    • Для каждого грузовика — своя горизонтальная «линия».
    • На линии размещаются слоты поставок (непересекающиеся).

2. Основные бизнес-сущности (вербальная модель)

2.1. Заказ

Заказ — требование клиента получить объем цемента в пределах смены.

  • Ключевые атрибуты:

    • Идентификатор заказа.
    • Клиент (объект, адрес).
    • Объем заказа: (V_{order}Vorder) (куб. м).
    • Временное окно клиента: желательное/обязательное окно разгрузки (например, 09:00–13:00).
    • Приоритет клиента: нормальный/высокий/критический.
    • Сервисные ограничения:
      • макс. допустимое опоздание,
      • жесткость окна (жесткое/мягкое).
    • Статус заказа: запланирован/в процессе/выполнен/частично выполнен/отменен.
  • Важный момент:
    Заказ разбивается на несколько поставок, каждая часть должна быть выполнена в рамках смены и по возможности в рамках окна клиента.

2.2. Поставка

Поставка — минимальная логистическая единица: одна поездка одной машины с определенным объемом к конкретному клиенту в конкретный интервал времени.

  • Ключевые атрибуты:

    • Идентификатор поставки.
    • Ссылка на заказ.
    • Грузовик (машина): тот, кто выполняет эту поставку.
    • Объем поставки: (V_{delivery}), не больше вместимости машины.
    • Точка погрузки: завод/бетонный узел.
    • Точка разгрузки: объект клиента.
    • Плановые времена:
      • время начала погрузки,
      • время окончания погрузки,
      • время прибытия к клиенту,
      • время начала разгрузки,
      • время окончания разгрузки,
      • время возвращения в пул (на базу или в состояние «свободен»).
    • Временной слот поставки: интервал, где главным моментом является время разгрузки (по факту или по плану).
    • Статус: запланирована/в пути/на погрузке/разгрузка/завершена/отменена/прерванна.
  • Суть:
    Поставка — это «кусок» заказа, который привязан к конкретной машине и конкретному слоту на ее Гант-линиии.

2.3. Машина (грузовик)

Машина — ресурс, который перемещается по временной шкале и может выполнять последовательность поставок.

  • Ключевые атрибуты:

    • Идентификатор машины.
    • Вместимость цистерны: 

      image.png

    • Местоположение в начале смены: база/объект/дорога.
    • Точка базирования: основная база, куда машина возвращается.
    • Техническое состояние: исправна/неисправна/в ремонте.
    • Статус доступности:
      • свободна (в пуле),
      • занята поставкой,
      • в пути без груза,
      • недоступна (форс-мажор).
    • Доступное время: момент, с которого машина может начать новую поставку (с учетом прошлых задач).
    • Водитель/смена водителя (опционально, можно включить позже).
  • Временная шкала машины:
    Набор не пересекающихся участков:

    • период свободен/ожидает,
    • период погрузка,
    • период в пути к клиенту,
    • период разгрузка,
    • период возврат.

2.4. Пул свободных машин

Пул свободных машин — динамическое множество машин, которые доступны для назначения на новую поставку.

  • Ключевые атрибуты пула в каждый момент:

    • список машин со статусом «доступна к началу новой поставки не позже нужного момента».
    • для каждой машины — координаты/расстояние до точки погрузки, доступное время, вместимость.
  • Критерии выбора машины (из условия):

    1. Близость к точке погрузки.
    2. Вместимость: достаточная для требуемого объема поставки.
    3. Приоритет клиента: машина может «выбиваться» из более дальних задач, если клиент приоритетный.
    4. Дополнительные опциональные критерии:
      • баланс пробега,
      • предпочтения по клиентам,
      • ограничения по доступу на объект.

2.5. Временной слот

Слот — отрезок времени, привязанный к машине и поставке, в который загрузка/дорога/разгрузка этой поставки «занимает» машину.

  • Ключевые атрибуты:

    • машина,
    • поставка,
    • интервал времени 

      image.png

    • главное время: 

      image.png

       — время разгрузки у клиента (или интервал разгрузки).
  • Критическое правило:
    Слоты одной и той же машины не пересекаются.
    На диаграмме Ганта это означает, что прямоугольники по одной линии не должны накладываться друг на друга.


3. Разбиение заказа на поставки

3.1. Логика разбиения

Допустим, заказ на  

image.png

кубометров, вместимость машины 

image.png

  • Минимальное количество поставок:
     

    image.png

  • Мы создаем 3 поставки: по 5 кубов каждая (или последние 5,5,5 — в общем случае можно иметь последнюю неполной, но обычно удобнее делать полные до последней).

3.2. Привязка к временному окну клиента

  • Если окно клиента 08:00–12:00, нужно разместить 3 слота (3 поставки) так, чтобы:

    • время разгрузки каждой поставки попадало в окно или,
    • если это невозможно, отклонение было минимальным (по приоритету клиента).
  • Разбиение может быть:

    • равномерным по времени,
    • или с учетом производительности точки погрузки (чтобы не создавать очередь на заводе).

4. Простая, визуализируемая математика времени

4.1. Структура времени одной поставки

Для каждой поставки можно считать:

  • Время цикла: 

    image.png


  • Плюс буфер форс-мажора (t_{buf}) (например, в процентах от суммарного времени в пути или фиксированное значение).

Общее время цикла для слота:  

image.png

На диаграмме Ганта это — длина прямоугольника слота.

4.2. Связь машин и слотов

  • Если машина завершила предыдущую поставку в момент  

    image.png

     то для новой поставки:
    •  

      image.png

    • выстраиваем времена этапов,
    • получаем  

      image.png

    • проверяем, попадает ли  

      image.png

      в окно клиента.

5. Алгоритм распределения машин в нормальном сценарии

5.1. Общий подход

Нам нужен алгоритм, который:

  1. Получает список заказов на смену.
  2. Разбивает каждый заказ на набор поставок.
  3. Для каждой поставки выбирает машину и слот.
  4. Обеспечивает непересечение слотов по машинам.
  5. Учитывает критерии выбора машины.

5.2. Порядок рассмотрения поставок

Возможная базовая стратегия:

  1. Сортируем поставки:

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

    • выбираем машину из пула по критериям,
    • строим слот,
    • проверяем отсутствие пересечений,
    • фиксируем назначение.

5.3. Выбор машины из пула

Для поставки (D) (из заказа клиента (C) с точкой погрузки (P)):

  • Формируем множество доступных машин:
    • Условие 1: машина будет свободна до времени начала погрузки (учитывая предыдущий слот).
    • Условие 2: вместимость машины  

      image.png

  • Для каждой такой машины оцениваем простую оценочную функцию, например:

image.png

где:

  • distance — расстояние/время до точки погрузки,

  • slack_time — «зазор» между доступным временем машины и началом слота (меньший — лучше).

  • Приоритет клиента:

    • для высокоприоритетных клиентов можно:
      • разрешать более жесткий выбор (например, допускается «растягивать» маршруты других клиентов чуть сильнее),
      • или ограничить выбор машин только теми, у кого минимальное отклонение от окна.

Выбираем машину с минимальным Score (или по другим простым правилам), создаем слот и отмечаем машину занятой на этот интервал.


6. Каскадный перерасчет при форс-мажоре

6.1. Виды форс-мажора (из условия)

Все форс-мажоры по сути сводятся к одному эффекту: машина либо выпадет из пула, либо вернется в пул позже:

  • поломка машины,
  • ДТП,
  • задержка на разгрузке.

Это значит:

  • поставка может:
    • быть отменена/заменена другой машиной,
    • смещена по времени,
    • частично выполнена (если это допускается).

6.2. Событийная модель форс-мажора

Ключевые события:

  • EVENT_MACHINE_BREAKDOWN — поломка машины.
  • EVENT_ACCIDENT — ДТП (фактически тот же эффект, что breakdown).
  • EVENT_UNLOAD_DELAY — задержка на разгрузке.

Каждое событие:

  • связано с конкретной машиной и поставкой,
  • дает новую оценку времени, когда машина:
    • либо вообще станет недоступной до конца смены,
    • либо станет свободной в другой момент (позже, чем планировалось).

6.3. Каскадный пересчет

Механизм:

  1. Локальное обновление:

    • Пересчитываем фактическое время окончания текущей поставки машины.
    • Обновляем статус машины и времени ее доступности.
  2. Обнаружение конфликтов:

    • Находим все следующие поставки, которые:
      • назначены на ту же машину,
      • их слоты пересекаются с новым временем недоступности.
  3. Для каждой конфликтной поставки:

    • Пытаемся переназначить:
      • ищем другую машину в пуле, которая может взять эту поставку:
        • в пределах окна клиента,
        • в пределах смены,
        • без пересечений слотов.
    • Если переназначить невозможно:
      • смещаем слот во времени (если окна клиента допускают),
      • либо помечаем поставку как риск/невыполнение и выдаем событие для диспетчера.
  4. Каскадность:

    • Переназначение или смещение одной поставки может создавать новые конфликты у других машин.
    • Поэтому алгоритм должен:
      • выполнять пересчет в порядке приоритета:
        • сначала критичные клиенты,
        • затем остальные,
      • ограничивать глубину перестановок (чтобы не «взорвать» всю схему).

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


7. Событийная модель и интерфейсы

7.1. Основные бизнес-события

  • EVENT_ORDER_CREATED — создан заказ.
  • EVENT_ORDER_SPLIT — заказ разбит на поставки.
  • EVENT_DELIVERY_PLANNED — поставка назначена машине и слоту.
  • EVENT_TRUCK_BECAME_AVAILABLE — машина стала свободной.
  • EVENT_TRUCK_BROKEN — поломка машины, выпадение из пула.
  • EVENT_ACCIDENT_OCCURRED — ДТП.
  • EVENT_UNLOAD_DELAY_OCCURRED — задержка на разгрузке.
  • EVENT_DELIVERY_STARTED — началась фактическая поездка.
  • EVENT_DELIVERY_COMPLETED — поставка завершена.
  • EVENT_DELIVERY_REASSIGNED — поставка переназначена на другую машину.
  • EVENT_SCHEDULE_RECOMPUTED — выполнен локальный/глобальный перерасчёт.

Каждое событие:

  • имеет:
    • идентификатор,
    • тип,
    • время возникновения,
    • ссылки на сущности (машина, поставка, заказ),
    • полезную нагрузку (новые времена, причины и т. п.).

7.2. Взаимодействия через интерфейсы

Можно выделить интерфейсы (на уровне доменной модели):

  • IOrderService

    • Создать заказ.
    • Разбить заказ на поставки.
    • Вернуть список поставок по заказу.
  • ISchedulingService

    • Запланировать поставки на смену.
    • Назначить поставку на машину.
    • Пересчитать расписание для конкретной машины/поставки.
  • ITruckPoolService

    • Получить доступные машины для заданного времени и объема.
    • Обновить статус машины (свободен/занят/неисправен).
  • IEventBus (событийная шина)

    • Опубликовать событие.
    • Подписаться на события.

Как только происходит форс-мажор, сервис реального времени или телематики публикует соответствующее событие, а подписанный ISchedulingService запускает перерасчет.


8. Типовые девиации мирового уровня и подводные камни

8.1. Типовые девиации

  • Реальные времена не совпадают с планом:

    • пробки,
    • длительная погрузка/разгрузка,
    • простой на объекте.
  • Частичное изменение заказа:

    • клиент уменьшает объем,
    • клиент увеличивает объем (и требует доп. поставки),
    • клиент просит сдвинуть окно разгрузки.
  • Изменения пула машин:

    • внеплановый ввод дополнительной машины,
    • вынужденный вывод машины из эксплуатации.
  • Внешние ограничения:

    • временное закрытие дороги,
    • ограничение по въезду на объект в определенные часы.

8.2. Подводные камни

  • Глобальный пересчет против локального:

    • Полная перестройка расписания при каждом событии может быть слишком дорогой и разрушать уже выполненные части плана.
    • Лучше локально пересчитывать только затронутую зону.
  • Приоритеты клиентов и справедливость:

    • Если всегда спасать приоритетных клиентов за счет остальных, можно сильно ухудшить сервис бюджетным заказам.
    • Нужна политика: сколько можно «ломать» чужие слоты ради приоритета.
  • Накопление сдвигов:

    • Маленькие сдвиги в начале смены через каскад могут привести к большинству поставок к концу смены с нарушениями.
    • Нужны лимиты на допустимую глубину переназначений и максимальные сдвиги.
  • Конфликты ресурсов на заводе:

    • Если много машин назначено на одно и то же время погрузки, образуется очередь.
    • Нужно учитывать производительность погрузочной точки (ограничение на количество машин в час).
  • Надо различать план и факт:

    • Модель должна хранить:
      • плановые времена,
      • фактические времена.
    • Пересчет должен работать с фактом, а не поверх уже устаревших планов.
  • Визуальный шум на диаграмме Ганта:

    • При частых перестроениях Ганта диспетчер перестает следить за изменениями.
    • Желательно выделять:
      • какие слоты были изменены,
      • какие поставки под риск.

9. Обобщенный событийный жизненный цикл поставки

  1. Создан заказ → EVENT_ORDER_CREATED.
  2. Заказ разбит на поставки → EVENT_ORDER_SPLIT.
  3. Для каждой поставки:
    • выбирается машина → EVENT_DELIVERY_PLANNED.
    • слот фиксируется в расписании.
  4. В момент начала отгрузки:
    • EVENT_DELIVERY_STARTED.
  5. Если всё идет по плану:
    • поставка завершается → EVENT_DELIVERY_COMPLETED,
    • машина возвращается в пул → EVENT_TRUCK_BECAME_AVAILABLE.
  6. Если форс-мажор:
    • публикуется соответствующее событие (поломка/ДТП/задержка),
    • запускается алгоритм локального перерасчета,
    • при переназначении → EVENT_DELIVERY_REASSIGNED, при массовом пересчете → EVENT_SCHEDULE_RECOMPUTED.

10. Сводный список акторов, атрибутов, событий, алгоритмов

10.1. Акторы (сущности)

  1. Заказ (Order)

    • Атрибуты:
      • идентификатор,
      • клиент,
      • объем,
      • окно разгрузки,
      • приоритет,
      • жесткость окна,
      • статус.
  2. Поставка (Delivery)

    • Атрибуты:
      • идентификатор,
      • ссылка на заказ,
      • машина,
      • объем,
      • точки погрузки/разгрузки,
      • плановые времена этапов,
      • слот ([t_{start}, t_{end}]),
      • ключевое время (t_{unload}),
      • статус.
  3. Машина (Truck)

    • Атрибуты:
      • идентификатор,
      • вместимость,
      • базовая точка,
      • начальное положение в смене,
      • техническое состояние,
      • статус доступности,
      • доступное время,
      • текущий/последний слот.
  4. Пул машин (TruckPool)

    • Атрибуты:
      • множество доступных машин,
      • критерии выбора (близость, вместимость, приоритет клиента, опциональные параметры).
  5. Смена (Shift)

    • Атрибуты:
      • начало и конец смены,
      • список заказов,
      • список поставок,
      • расписание (диаграмма Ганта).
  6. Расписание (Schedule)

    • Атрибуты:
      • для каждой машины — список слотов (непересекающихся),
      • фактические и плановые времена,
      • отметки о перерасчетах.

10.2. События

  • EVENT_ORDER_CREATED
  • EVENT_ORDER_SPLIT
  • EVENT_DELIVERY_PLANNED
  • EVENT_DELIVERY_STARTED
  • EVENT_DELIVERY_COMPLETED
  • EVENT_TRUCK_BECAME_AVAILABLE
  • EVENT_TRUCK_BROKEN
  • EVENT_ACCIDENT_OCCURRED
  • EVENT_UNLOAD_DELAY_OCCURRED
  • EVENT_DELIVERY_REASSIGNED
  • EVENT_SCHEDULE_RECOMPUTED

(Для каждого события — минимум: id, тип, время, ссылки на сущности, полезная нагрузка.)


10.3. Алгоритмы

  1. Алгоритм разбиения заказа на поставки

    • Вход: объем заказа, вместимости доступных машин, ограничения клиента (окно).
    • Выход: список поставок с объемами.
    • Параметры: стратегия округления (последняя поставка может быть меньше, но не больше вместимости машины).
  2. Алгоритм планирования смены (первичное расписание)

    • Вход: список заказов смены.
    • Шаги:
      • разбиение заказов на поставки,
      • сортировка поставок по приоритету и дедлайнам,
      • последовательный выбор машин из пула для каждой поставки.
    • Параметры: веса в функции выбора машины, правила разрешения конфликтов при недостатке ресурсов.
  3. Алгоритм выбора машины для поставки

    • Вход: поставка, текущее состояние пула.
    • Шаги:
      • фильтрация по вместимости и доступному времени,
      • оценка расстояния до точки погрузки,
      • вычисление оценочного Score,
      • выбор машины с минимальным Score.
    • Параметры: веса критериев, настройки для приоритетных клиентов.
  4. Алгоритм каскадного перерасчета

    • Вход: событие форс-мажора (с новой оценкой доступности машины).
    • Шаги:
      • обновление статуса машины и ее будущих слотов,
      • поиск конфликтующих поставок,
      • попытка переназначения на другие машины,
      • при невозможности — сдвиг во времени или пометка риска,
      • ограничение глубины каскада.
    • Параметры: максимальная глубина перестроения, приоритеты клиентов, допустимые отклонения окон.
  5. Алгоритм актуализации расписания (online update)

    • Вход: фактические времена и статусы поставок,
    • Задача: постоянно поддерживать:
      • актуальные свободные/занятые интервалы машин,
      • фактическое отклонение от плана,
      • метки риска для будущих поставок.

Раз уж мы с тобой уже проговорили домен «вширь», двинемся «вглубь», но без академического перебора: форматы сущностей, события, интерфейсы и эскиз алгоритмов, которые можно реально кодить и визуализировать.


1. Формат слота, поставки и расписания

1.1. Формат слота машины

Слот — минимальная единица диаграммы Ганта на линии машины.

Slot {
    SlotId: string
    TruckId: string
    DeliveryId: string

    // Интервал по шкале смены
    StartTime: datetime   // момент, когда машина занята под эту поставку (от выезда или от начала погрузки)
    EndTime: datetime     // момент, когда машина снова свободна

    // Ключевые моменты внутри слота
    LoadStartTime: datetime
    LoadEndTime: datetime
    TravelToClientStartTime: datetime
    TravelToClientEndTime: datetime
    UnloadStartTime: datetime
    UnloadEndTime: datetime
    ReturnStartTime: datetime
    ReturnEndTime: datetime

    // Главный маркер слота
    MainEventTime: datetime  // обычно UnloadStartTime или UnloadEndTime

    // Служебное
    Status: enum { Planned, InProgress, Completed, Cancelled, Reassigned }
    IsAffectedByRecalc: bool  // флаг, что слот изменён в результате перерасчёта
}

Визуализация: слот — это прямоугольник от StartTime до EndTime, с выделенной точкой/подсечкой в районе MainEventTime (разгрузка).

1.2. Формат поставки

Delivery {
    DeliveryId: string
    OrderId: string

    ClientId: string
    LoadPointId: string
    UnloadPointId: string

    Volume: float

    // Требования по времени со стороны клиента
    ClientWindowStart: datetime
    ClientWindowEnd: datetime
    MaxAllowedDelay: duration  // допустимое опоздание

    Priority: enum { Low, Normal, High, Critical }

    // Назначение
    TruckId: string?    // null, если ещё не назначена
    SlotId: string?     // ссылка на слот

    // План и факт
    PlannedUnloadTime: datetime?
    ActualUnloadTime: datetime?

    Status: enum {
        Planned,
        Assigned,
        InTransit,
        Unloading,
        Completed,
        Cancelled,
        Failed
    }

    // Диагностика
    IsAtRisk: bool      // флаг риска (по результатам прогноза/перерасчёта)
}

1.3. Расписание машины и смены

TruckSchedule {
    TruckId: string
    ShiftId: string
    Slots: List<Slot>  // всегда отсортированы по StartTime
}

ShiftSchedule {
    ShiftId: string
    ShiftStart: datetime
    ShiftEnd: datetime

    TruckSchedules: List<TruckSchedule>
    Deliveries: List<Delivery>
}

Инвариант:
для каждого TruckSchedule.Slots — слоты не пересекаются по времени.


2. Формат событий и шина событий

2.1. Базовый контракт события

DomainEvent {
    EventId: string
    EventType: string
    OccurredAt: datetime    // когда событие произошло в реальности (или зафиксировано)
    SourceSystem: string    // телематика, диспетчерский UI, планировщик, и т.п.
    Payload: object         // тип зависит от EventType
    CorrelationId: string?  // чтобы цеплять связанные события/команды
}

2.2. Ключевые типы событий

OrderCreatedEvent {
    OrderId: string
    ClientId: string
    Volume: float
    ClientWindowStart: datetime
    ClientWindowEnd: datetime
    Priority: string
}

OrderSplitEvent {
    OrderId: string
    DeliveryIds: List<string>
}

DeliveryPlannedEvent {
    DeliveryId: string
    TruckId: string
    SlotId: string
    PlannedUnloadTime: datetime
}

DeliveryStartedEvent {
    DeliveryId: string
    TruckId: string
    SlotId: string
}

DeliveryCompletedEvent {
    DeliveryId: string
    TruckId: string
    SlotId: string
    ActualUnloadTime: datetime
}

TruckBecameAvailableEvent {
    TruckId: string
    AvailableFrom: datetime
}

TruckBrokenEvent {
    TruckId: string
    BreakdownTime: datetime
    ExpectedBackOnline: datetime?  // null, если выбыл до конца смены
}

AccidentOccurredEvent {
    TruckId: string
    DeliveryId: string?
    AccidentTime: datetime
    ExpectedBackOnline: datetime?
}

UnloadDelayOccurredEvent {
    TruckId: string
    DeliveryId: string
    NewExpectedUnloadEndTime: datetime
}

Системные события перерасчета:

ScheduleRecomputedEvent {
    ShiftId: string
    AffectedTruckIds: List<string>
    AffectedDeliveryIds: List<string>
    RecalcScope: enum { Local, Extended, Global }
}

DeliveryReassignedEvent {
    DeliveryId: string
    OldTruckId: string
    NewTruckId: string
    OldSlotId: string
    NewSlotId: string
}

3. Интерфейсы сервисов (контуры)

Условно — ядро домена, поверх которого можно строить любой UI/интеграцию.

3.1. Работа с заказами

interface IOrderService {
    Order CreateOrder(CreateOrderRequest request);
    List<Delivery> SplitOrderIntoDeliveries(string orderId);
    Order GetOrder(string orderId);
    List<Delivery> GetOrderDeliveries(string orderId);
}

CreateOrderRequest {
    ClientId: string
    Volume: float
    ClientWindowStart: datetime
    ClientWindowEnd: datetime
    Priority: string
}

SplitOrderIntoDeliveries при вызове генерирует OrderSplitEvent.

3.2. Пул машин и доступность

interface ITruckPoolService {
    List<Truck> GetAvailableTrucks(TimeWindow timeWindow, float requiredVolume);

    // Обновление статусов
    void SetTruckStatus(string truckId, TruckStatus status);
    void UpdateTruckAvailableFrom(string truckId, datetime availableFrom);
    Truck GetTruck(string truckId);
}

Truck {
    TruckId: string
    Capacity: float
    BaseLocationId: string
    CurrentLocationId: string
    Status: TruckStatus
    AvailableFrom: datetime
}

enum TruckStatus {
    Available,
    Busy,
    Broken,
    OutOfService
}

TimeWindow {
    Start: datetime
    End: datetime
}

3.3. Планировщик и расписание

interface ISchedulingService {
    ShiftSchedule BuildInitialSchedule(string shiftId, List<Order> orders);

    // Назначение/переназначение одной поставки
    AssignmentResult AssignDelivery(string shiftId, string deliveryId);

    // Каскадный пересчет для события
    RecalcResult RecalculateSchedule(string shiftId, DomainEvent triggerEvent);

    ShiftSchedule GetSchedule(string shiftId);
}

AssignmentResult {
    bool Success;
    string? TruckId;
    string? SlotId;
    string? ReasonIfFailed;
}

RecalcResult {
    bool Success;
    List<string> AffectedDeliveries;
    List<string> AffectedTrucks;
    string Scope; // Local / Extended / Global (как диагностический маркер)
}

3.4. Шина событий

interface IEventBus {
    void Publish(DomainEvent evt);
    void Subscribe(string eventType, Action<DomainEvent> handler);
}

На практике:
ISchedulingService подписывается на TruckBrokenEvent, AccidentOccurredEvent, UnloadDelayOccurredEvent, TruckBecameAvailableEvent и запускает локальный пересчет.


4. Выбор между локальным и глобальным пересчетом

4.1. Критерии выбора стратегии

Локальный пересчет — минимальное вмешательство:

  • триггер: поломка/задержка одной машины или одной поставки,
  • изменяем:
    • будущие слоты только этой машины и тех машин, которым перекинули её поставки,
  • не трогаем:
    • слоты, уже выполняемые или уже выполненные,
    • слоты удалённых по времени машин, если можно избежать.

Глобальный пересчет — перекомпоновка существенной части смены:

  • триггер:
    • массовый сдвиг (крупная авария/закрытие трассы),
    • новая критичная партия заказов в середине смены,
    • потеря нескольких ключевых машин,
  • изменяем:
    • большие фрагменты расписания,
  • риск:
    • диспетчер и водители получают много изменений сразу.

4.2. Простая логика решения

enum RecalcMode { Local, Extended, Global }

RecalcMode ChooseRecalcMode(TriggerContext ctx) {
    // ctx содержит тип события, количество затронутых поставок, приоритеты, время до конца смены и т.п.

    if (ctx.AffectedTrucksCount == 1
        && ctx.AffectedDeliveriesCount <= LocalThreshold
        && ctx.TimeToShiftEnd < LocalTimeWindowLimit) {
        return RecalcMode.Local;
    }

    if (ctx.AffectedTrucksCount <= ExtendedTruckLimit
        && ctx.AffectedDeliveriesCount <= ExtendedDeliveryLimit) {
        return RecalcMode.Extended;
    }

    return RecalcMode.Global;
}

Где, например:

  • LocalThreshold — 3–5 поставок,
  • ExtendedTruckLimit — 2–3 машины,
  • ExtendedDeliveryLimit — 10–15 поставок.

5. Псевдокод планировщика: первичное планирование

5.1. Разбиение заказа на поставки

Простой вариант: все поставки одинакового объема, кроме, возможно, последней.

List<Delivery> SplitOrderIntoDeliveries(Order order, float defaultTruckCapacity) {
    deliveries = []

    remaining = order.Volume
    while (remaining > 0) {
        v = min(remaining, defaultTruckCapacity) // либо более умно учитывать конкретные машины
        delivery = new Delivery {
            DeliveryId = NewId(),
            OrderId = order.OrderId,
            Volume = v,
            ClientId = order.ClientId,
            LoadPointId = order.LoadPointId,
            UnloadPointId = order.UnloadPointId,
            ClientWindowStart = order.ClientWindowStart,
            ClientWindowEnd = order.ClientWindowEnd,
            Priority = order.Priority,
            Status = Planned
        }
        deliveries.add(delivery)
        remaining -= v
    }

    return deliveries
}

5.2. Планирование всех поставок смены

ShiftSchedule BuildInitialSchedule(Shift shift, List<Order> orders) {
    // 1. Разбиваем заказы на поставки
    allDeliveries = []
    foreach (order in orders) {
        ds = SplitOrderIntoDeliveries(order, defaultTruckCapacity)
        allDeliveries.addAll(ds)
    }

    // 2. Сортируем поставки: по приоритету и раннему окончанию окна клиента
    allDeliveries.sortBy(DeliveryPriority desc, ClientWindowEnd asc)

    // 3. Инициализируем расписание
    schedule = new ShiftSchedule {
        ShiftId = shift.Id,
        ShiftStart = shift.Start,
        ShiftEnd = shift.End,
        TruckSchedules = initTruckSchedules(shift.Trucks),
        Deliveries = allDeliveries
    }

    // 4. Идем по поставкам и назначаем
    foreach (delivery in allDeliveries) {
        result = AssignDelivery(schedule, delivery.DeliveryId)
        if (!result.Success) {
            // помечаем как риск/невыполнено, подсвечиваем в UI
            delivery.IsAtRisk = true
            delivery.Status = Planned // но с флагом риска
        }
    }

    return schedule
}

6. Псевдокод AssignDelivery: выбор машины и слота

6.1. Расчет временного профиля для кандидата

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

SlotCandidate? BuildSlotCandidate(TruckSchedule truckSchedule, Delivery delivery, TravelTimes travelTimes) {
    // 1. Определяем момент, когда машина свободна
    availableFrom = truckSchedule.GetAvailableFrom() // конец последнего слота или начало смены

    // 2. Учитываем, что машине нужно доехать до точки погрузки
    travelToLoadTime = travelTimes.GetTravelTime(truckSchedule.TruckId, currentLocation(truckSchedule), delivery.LoadPointId)

    loadStart = max(availableFrom + travelToLoadTime, delivery.ClientWindowStart - someBackOffset)
    loadEnd = loadStart + travelTimes.LoadDuration(delivery)
    toClientStart = loadEnd
    toClientEnd = toClientStart + travelTimes.GetTravelTime(delivery.LoadPointId, delivery.UnloadPointId)
    unloadStart = toClientEnd
    unloadEnd = unloadStart + travelTimes.UnloadDuration(delivery)
    returnStart = unloadEnd
    returnEnd = returnStart + travelTimes.GetTravelTime(delivery.UnloadPointId, truckSchedule.BaseLocationId)

    // + буфер форс-мажора (можно добавить как коэффициент к travel times)
    buffer = travelTimes.BufferForUncertainty(delivery)
    returnEnd += buffer

    // Проверяем, попадает ли разгрузка в окно или в допустимые отклонения
    if (unloadStart > delivery.ClientWindowEnd + delivery.MaxAllowedDelay) {
        return null // этот грузовик не подходит с точки зрения времени
    }

    // Проверяем, не вылезаем ли за пределы смены
    if (returnEnd > truckSchedule.ShiftEnd) {
        return null
    }

    // Собираем слот-кандидат
    slot = new Slot {
        SlotId = NewId(),
        TruckId = truckSchedule.TruckId,
        DeliveryId = delivery.DeliveryId,
        StartTime = loadStart,
        EndTime = returnEnd,
        LoadStartTime = loadStart,
        LoadEndTime = loadEnd,
        TravelToClientStartTime = toClientStart,
        TravelToClientEndTime = toClientEnd,
        UnloadStartTime = unloadStart,
        UnloadEndTime = unloadEnd,
        ReturnStartTime = returnStart,
        ReturnEndTime = returnEnd,
        MainEventTime = unloadStart,
        Status = Planned
    }

    // Проверяем пересечение с уже существующими слотами
    if (truckSchedule.IntersectsWithExistingSlots(slot)) {
        return null
    }

    return slot
}

6.2. Функция AssignDelivery

AssignmentResult AssignDelivery(ShiftSchedule schedule, string deliveryId) {
    delivery = schedule.Deliveries.find(d => d.DeliveryId == deliveryId)

    // 1. Получаем список потенциально доступных машин из пула
    //    (фильтр по вместимости и техническому состоянию)
    candidateTrucks = TruckPoolService.GetAvailableTrucks(
        timeWindow: new TimeWindow(schedule.ShiftStart, schedule.ShiftEnd),
        requiredVolume: delivery.Volume
    )

    bestScore = +∞
    bestSlot = null
    bestTruckSchedule = null

    foreach (truck in candidateTrucks) {
        truckSchedule = schedule.TruckSchedules.find(ts => ts.TruckId == truck.TruckId)

        slotCandidate = BuildSlotCandidate(truckSchedule, delivery, travelTimes)
        if (slotCandidate == null) continue

        score = ScoreTruckForDelivery(truck, truckSchedule, delivery, slotCandidate)

        if (score < bestScore) {
            bestScore = score
            bestSlot = slotCandidate
            bestTruckSchedule = truckSchedule
        }
    }

    if (bestSlot == null) {
        return new AssignmentResult {
            Success = false,
            ReasonIfFailed = "No feasible truck found for delivery"
        }
    }

    // 2. Фиксируем назначение
    bestTruckSchedule.Slots.add(bestSlot)
    bestTruckSchedule.SortSlotsByStartTime()

    delivery.TruckId = bestSlot.TruckId
    delivery.SlotId = bestSlot.SlotId
    delivery.PlannedUnloadTime = bestSlot.UnloadStartTime
    delivery.Status = Assigned

    // 3. Генерируем событие
    EventBus.Publish(new DeliveryPlannedEvent {
        DeliveryId = delivery.DeliveryId,
        TruckId = bestSlot.TruckId,
        SlotId = bestSlot.SlotId,
        PlannedUnloadTime = bestSlot.UnloadStartTime
    })

    return new AssignmentResult {
        Success = true,
        TruckId = bestSlot.TruckId,
        SlotId = bestSlot.SlotId
    }
}

6.3. Функция ScoreTruckForDelivery

float ScoreTruckForDelivery(Truck truck, TruckSchedule truckSchedule, Delivery delivery, SlotCandidate slot) {
    // 1. Расстояние до точки погрузки
    distanceToLoad = metrics.Distance(truck.CurrentLocationId, delivery.LoadPointId)

    // 2. Зазор по времени (slack) — чем меньше, тем лучше
    availableFrom = truckSchedule.GetAvailableFrom()
    slack = (slot.LoadStartTime - availableFrom).TotalMinutes

    // 3. Отклонение по окну клиента (штрафуем, но допускаем)
    windowPenalty = 0
    if (slot.UnloadStartTime < delivery.ClientWindowStart) {
        windowPenalty = (delivery.ClientWindowStart - slot.UnloadStartTime).TotalMinutes
    } else if (slot.UnloadStartTime > delivery.ClientWindowEnd) {
        windowPenalty = (slot.UnloadStartTime - delivery.ClientWindowEnd).TotalMinutes
    }

    // 4. Приоритет клиента — коэффициент
    priorityFactor = delivery.Priority switch {
        Critical => 0.5,
        High => 0.8,
        Normal => 1.0,
        Low => 1.2
    }

    // Итог: чем меньше score, тем лучше
    score = priorityFactor * (
                wDistance * distanceToLoad +
                wSlack * slack +
                wWindow * windowPenalty
            )

    return score
}

7. Псевдокод каскадного пересчёта при форс-мажоре

7.1. Обработчик события поломки/аварии

RecalcResult RecalculateSchedule(string shiftId, DomainEvent triggerEvent) {
    schedule = GetSchedule(shiftId)

    if (triggerEvent is TruckBrokenEvent b) {
        return HandleTruckUnavailable(schedule, b.TruckId, b.BreakdownTime, b.ExpectedBackOnline)
    }

    if (triggerEvent is AccidentOccurredEvent a) {
        return HandleTruckUnavailable(schedule, a.TruckId, a.AccidentTime, a.ExpectedBackOnline)
    }

    if (triggerEvent is UnloadDelayOccurredEvent u) {
        return HandleUnloadDelay(schedule, u.DeliveryId, u.NewExpectedUnloadEndTime)
    }

    // другие типы — по мере необходимости
}

7.2. Машина становится недоступной

RecalcResult HandleTruckUnavailable(ShiftSchedule schedule, string truckId, datetime fromTime, datetime? expectedBackOnline) {
    affectedDeliveries = []
    affectedTrucks = [truckId]

    truckSchedule = schedule.TruckSchedules.find(ts => ts.TruckId == truckId)
    if (truckSchedule == null) return new RecalcResult { Success = true }

    // 1. Найти все слоты, которые начинаются после fromTime
    futureSlots = truckSchedule.Slots.where(s => s.StartTime >= fromTime)

    // 2. Для каждой поставки попытаться переназначить
    foreach (slot in futureSlots) {
        delivery = schedule.Deliveries.find(d => d.DeliveryId == slot.DeliveryId)
        if (delivery == null) continue

        // освобождаем слот
        truckSchedule.Slots.remove(slot)
        delivery.TruckId = null
        delivery.SlotId = null
        delivery.Status = Planned

        // пытаемся переназначить
        result = AssignDelivery(schedule, delivery.DeliveryId)
        if (!result.Success) {
            delivery.IsAtRisk = true
        } else {
            affectedTrucks.add(result.TruckId)
        }

        affectedDeliveries.add(delivery.DeliveryId)
    }

    // 3. Обновляем статус машины
    TruckPoolService.SetTruckStatus(truckId, TruckStatus.Broken)
    TruckPoolService.UpdateTruckAvailableFrom(truckId, expectedBackOnline ?? schedule.ShiftEnd)

    // 4. Публикуем событие
    EventBus.Publish(new ScheduleRecomputedEvent {
        ShiftId = schedule.ShiftId,
        AffectedTruckIds = affectedTrucks.Distinct().ToList(),
        AffectedDeliveryIds = affectedDeliveries
        // Scope определяем по количеству
    })

    return new RecalcResult {
        Success = true,
        AffectedDeliveries = affectedDeliveries,
        AffectedTrucks = affectedTrucks.Distinct().ToList()
    }
}

7.3. Задержка на разгрузке

RecalcResult HandleUnloadDelay(ShiftSchedule schedule, string deliveryId, datetime newUnloadEnd) {
    delivery = schedule.Deliveries.find(d => d.DeliveryId == deliveryId)
    if (delivery == null) return new RecalcResult { Success = true }

    truckSchedule = schedule.TruckSchedules.find(ts => ts.TruckId == delivery.TruckId)
    if (truckSchedule == null) return new RecalcResult { Success = true }

    currentSlot = truckSchedule.Slots.find(s => s.DeliveryId == deliveryId)
    if (currentSlot == null) return new RecalcResult { Success = true }

    // 1. Обновляем текущий слот
    delay = newUnloadEnd - currentSlot.UnloadEndTime
    currentSlot.UnloadEndTime = newUnloadEnd
    currentSlot.ReturnStartTime = newUnloadEnd
    currentSlot.ReturnEndTime += delay
    currentSlot.EndTime = currentSlot.ReturnEndTime

    // 2. Находим последующие слоты этой машины, пересекающиеся с новым EndTime
    futureSlots = truckSchedule.Slots
        .where(s => s.StartTime < currentSlot.EndTime && s.StartTime > currentSlot.StartTime)

    affectedDeliveries = [deliveryId]
    affectedTrucks = [truckSchedule.TruckId]

    foreach (slot in futureSlots) {
        d = schedule.Deliveries.find(x => x.DeliveryId == slot.DeliveryId)
        if (d == null) continue

        // Снимаем слот, помечаем как Planned
        truckSchedule.Slots.remove(slot)
        d.TruckId = null
        d.SlotId = null
        d.Status = Planned

        // Пробуем переназначить
        result = AssignDelivery(schedule, d.DeliveryId)
        if (!result.Success) {
            d.IsAtRisk = true
        } else {
            affectedTrucks.add(result.TruckId)
        }

        affectedDeliveries.add(d.DeliveryId)
    }

    // 3. Публикуем событие
    EventBus.Publish(new ScheduleRecomputedEvent {
        ShiftId = schedule.ShiftId,
        AffectedTruckIds = affectedTrucks.Distinct().ToList(),
        AffectedDeliveryIds = affectedDeliveries.Distinct().ToList()
    })

    return new RecalcResult {
        Success = true,
        AffectedDeliveries = affectedDeliveries.Distinct().ToList(),
        AffectedTrucks = affectedTrucks.Distinct().ToList()
    }
}

8. На что тут стоит обратить особое внимание (как архитектору)

  • Инварианты расписания:

    • слоты в TruckSchedule не пересекаются,
    • каждый Delivery имеет не более одного активного слота,
    • плановые и фактические времена не смешиваются.
  • Явная локальность изменений:

    • перерасчёт всегда начинается от конкретного события,
    • зона каскада ограничивается:
      • машиной,
      • её «хвостом» слотов,
      • и машинами, на которые перекинули поставки.
  • Событийность вместо «магии в лоб»:

    • любой значимый сдвиг — событие,
    • любой перерасчет — тоже событие, с Affected*, чтобы UI и отчётность могли подсветить последствия.
  • Простая математика:

    • всё сводится к сложению интервалов и проверке пересечений,
    • наглядно мапится на Гант:
      • после перерасчёта у тебя всего лишь изменились StartTime/EndTime пары прямоугольников и их привязка к линиям.

Сфокусируемся на двух вещах:

  1. как это всё хранить (таблицы/коллекции + связи),
  2. как эволюционировать архитектуру от оффлайн-планировщика к онлайн-событийной системе без Big Bang.

1. Модель хранения данных (РСУБД / документо-ориентированно)

Я опишу в реляционном стиле, но структура хорошо ложится и на документы.

1.1. Таблица смен

Shifts

  • ShiftId (PK)
  • StartTime
  • EndTime
  • Status (Planned, InProgress, Completed, Cancelled)
  • CreatedAt
  • UpdatedAt

Смена — контейнер для расписания, заказов, поставок и слотов.


1.2. Таблица клиентов и точек

Clients

  • ClientId (PK)
  • Name
  • Address
  • PriorityDefault (Low/Normal/High/Critical)
  • CreatedAt
  • UpdatedAt

Locations (можно использовать для заводов, баз, объектов)

  • LocationId (PK)
  • Type (Plant, ClientSite, Depot, Other)
  • Name
  • Address
  • Latitude, Longitude (опционально)
  • CreatedAt
  • UpdatedAt

1.3. Таблица заказов

Orders

  • OrderId (PK)
  • ShiftId (FK → Shifts)
  • ClientId (FK → Clients)
  • LoadPointId (FK → Locations, Type=Plant)
  • UnloadPointId (FK → Locations, Type=ClientSite)
  • TotalVolume
  • ClientWindowStart
  • ClientWindowEnd
  • MaxAllowedDelay (в минутах)
  • Priority (Low/Normal/High/Critical)
  • Status (Planned, Split, InProgress, Completed, PartiallyCompleted, Cancelled)
  • CreatedAt
  • UpdatedAt

1.4. Таблица машин и их статусов

Trucks

  • TruckId (PK)
  • RegistrationNumber
  • Capacity (максимальный объем цемента)
  • BaseLocationId (FK → Locations, Type=Depot/Plant)
  • IsActive (bool)
  • CreatedAt
  • UpdatedAt

TruckShiftStates (состояние машины в конкретной смене)

  • TruckShiftStateId (PK)
  • ShiftId (FK → Shifts)
  • TruckId (FK → Trucks)
  • InitialLocationId (FK → Locations)
  • Status (Available, Busy, Broken, OutOfService)
  • AvailableFrom (временная отметка в пределах смены)
  • CurrentLocationId (можно обновлять по факту)
  • CreatedAt
  • UpdatedAt

Эта таблица задаёт стартовые условия для планировщика и обновляется по мере смены.


1.5. Таблица поставок

Deliveries

  • DeliveryId (PK)
  • OrderId (FK → Orders)
  • ShiftId (FK → Shifts) — дублируем для удобства запросов
  • ClientId (FK → Clients)
  • LoadPointId (FK → Locations)
  • UnloadPointId (FK → Locations)
  • Volume
  • ClientWindowStart
  • ClientWindowEnd
  • MaxAllowedDelay
  • Priority (копия приоритета заказа на момент разбиения)
  • TruckId (FK → Trucks, nullable — если ещё не назначена)
  • SlotId (FK → Slots, nullable)
  • PlannedUnloadTime (nullable)
  • ActualUnloadTime (nullable)
  • Status (Planned, Assigned, InTransit, Unloading, Completed, Cancelled, Failed)
  • IsAtRisk (bool)
  • CreatedAt
  • UpdatedAt

1.6. Таблица слотов (диаграмма Ганта)

Slots

  • SlotId (PK)
  • ShiftId (FK → Shifts)
  • TruckId (FK → Trucks)
  • DeliveryId (FK → Deliveries)
  • StartTime
  • EndTime
  • LoadStartTime
  • LoadEndTime
  • TravelToClientStartTime
  • TravelToClientEndTime
  • UnloadStartTime
  • UnloadEndTime
  • ReturnStartTime
  • ReturnEndTime
  • MainEventTime (обычно UnloadStartTime)
  • Status (Planned, InProgress, Completed, Cancelled, Reassigned)
  • IsAffectedByRecalc (bool)
  • CreatedAt
  • UpdatedAt

Инвариант, который нужно обеспечивать на уровне приложения (или проверками):

  • для каждой пары (ShiftId, TruckId) интервалы [StartTime, EndTime] не пересекаются.

1.7. Таблица событий домена (event log / outbox)

DomainEvents

  • EventId (PK)
  • EventType (string, например "TruckBroken", "UnloadDelayOccurred")
  • OccurredAt
  • ShiftId (nullable)
  • TruckId (nullable)
  • DeliveryId (nullable)
  • OrderId (nullable)
  • PayloadJson (JSON) — полный контекст события
  • SourceSystem (Planner, Telematics, DispatcherUI, etc.)
  • CorrelationId (nullable)
  • ProcessedFlags (опционально: битовая маска/несколько колонок для разных потребителей)
  • CreatedAt

Это может быть и отдельный лог/таблица, и реализация паттерна Outbox для надёжной публикации в message-broker.


2. «Минимальный оффлайн-планировщик» — стартовая архитектура

Исходная точка:
у тебя есть только планировщик, который:

  • получает список заказов и машин на смену,
  • строит расписание (слоты) оффлайн,
  • диспетчер смотрит Гант, распечатывает/экспортирует задания,
  • факт из реального мира не интегрирован или подтягивается вручную.

2.1. Компоненты

  1. База данных с таблицами:
    • Shifts, Orders, Trucks, TruckShiftStates, Deliveries, Slots.
  2. Планировщик (batch/сервис):
    • метод BuildInitialSchedule(shiftId):
      • читает заказы и машины по смене,
      • разбиение заказов на Deliveries,
      • AssignDelivery → создание Slots,
      • обновление Deliveries и Slots в БД.
  3. UI планировщика/диспетчера:
    • экран «Список смен»,
    • экран «Гант смены»:
      • линии — Trucks,
      • прямоугольники — Slots,
      • клики по slot → детали Delivery.

На этом этапе события ещё не обязательны: всё можно делать транзакционно, без DomainEvents (или с минимальным логом).


3. Переход к онлайн-событийной модели: инкрементальная миграция

Цель:
не ломая оффлайн-планировщик, постепенно добавить:

  • события из реальности (теlematics/ручной ввод),
  • реакцию планировщика на форс-мажоры,
  • каскадный перерасчёт.

3.1. Шаг 1 — ввести лог доменных событий (passive mode)

Добавляем таблицу DomainEvents, но планировщик пока её только заполняет:

  • при создании смены,
  • при разбиении заказов,
  • при планировании поставок DeliveryPlannedEvent,
  • при любом ручном изменении слотов/назначений в UI.

Потребителей у этих событий пока может не быть (или только аналитика/логирование).

Изменения минимальны:

  • в коде сервисов (IOrderService, ISchedulingService) после успешных операций:
    • записать запись в DomainEvents.

Это не ломает текущий сценарий: планировщик по-прежнему «оффлайн», но события уже есть.


3.2. Шаг 2 — подключить внешний факт (telematics / ручной ввод) через те же события

Теперь реальные изменения состояния (поломка, ДТП, задержка):

  • либо приходят из внешней системы и пишутся в DomainEvents,
  • либо диспетчер вручную их создаёт через UI (форма «Зафиксировать поломку / задержку»), что тоже пишет запись в DomainEvents.

Никакой реактивности пока нет:

  • события лишь лежат в таблице,
  • диспетчер может смотреть их список, но планировщик сам по себе не реагирует.

Зато мы выравниваем протокол:
и план, и факт идут через один формат событий.


3.3. Шаг 3 — фоновый обработчик событий с локальным пересчетом

Добавляем фоновый сервис (или процесс):

  • EventProcessor:
    • периодически (или по триггеру) читает новые DomainEvents, которых ещё не обработал,
    • для событий типа:
      • TruckBrokenEvent,
      • AccidentOccurredEvent,
      • UnloadDelayOccurredEvent,
    • вызывает ISchedulingService.RecalculateSchedule(shiftId, event).

Паттерн Outbox:

  • запись в DomainEvents — часть транзакции бизнес-операции,
  • обработчик считывает события, помечает их как обработанные (флаги ProcessedByPlanner и т. п.).

На этом этапе важно:

  • не переписывать существующую логику планировщика, а надстроить над ней:
    • BuildInitialSchedule остаётся как есть,
    • RecalculateSchedule — новая функция, которая:
      • читает текущее расписание смены из БД,
      • применяет локальный пересчет (по псевдокоду из предыдущего сообщения),
      • обновляет Deliveries и Slots,
      • пишет ScheduleRecomputedEvent в DomainEvents.

3.4. Шаг 4 — постепенное расширение событийности UI

После того как RecalculateSchedule стал работать, можно:

  • на UI Ганта:
    • раз в N секунд опрашивать БД/сервис и обновлять вид,
    • подсвечивать слоты с IsAffectedByRecalc = true,
    • подсвечивать Deliveries.IsAtRisk = true.

Опционально:

  • добавить «историю изменений» слота:
    • отдельная таблица SlotHistory или AuditLog:
      • SlotId, OldStartTime, OldEndTime, NewStartTime, NewEndTime, Reason, EventId.

Это можно делать тоже инкрементально.


4. Ограничение риска и сохранение «старого мира»

Критичная часть миграции — не ломать привычный сценарий диспетчеров и обеспечить «откатность».

4.1. Версионирование расписания

Один из безопасных подходов:

  • вводим версию расписания смены:

ShiftSchedules

  • ShiftScheduleId (PK)
  • ShiftId (FK → Shifts)
  • Version (int)
  • CreatedAt
  • CreatedBy (система/пользователь)
  • IsActive (bool)

А в таблицах Slots и Deliveries добавляем колонку:

  • ShiftScheduleId (FK → ShiftSchedules).

Тогда:

  • BuildInitialSchedule создаёт версию 1,
  • RecalculateSchedule создаёт версию 2:
    • копирует актуальные Deliveries/Slots в новую версию с изменениями,
    • «старую» версию можно хранить как архив или сделать неактивной.

Плюсы:

  • можно откатиться к предыдущей версии (для анализа или в аварийной ситуации),
  • легко сравнивать версии (какие слоты изменились).

Минусы:

  • чуть больше объём данных,
  • нужна аккуратная работа с «активной версией».

Если не хочется сразу вводить полное версионирование, можно начать с простой схемы:

  • флаг IsAffectedByRecalc + историю изменений только для слотов.

5. Минимальный путь миграции (очень прагматично)

Если сжать всё до «минимального жизнеспособного» пути:

  1. Stage 0 — оффлайн-планировщик.

    • Есть Shifts, Orders, Trucks, TruckShiftStates, Deliveries, Slots.
    • Есть BuildInitialSchedule.
    • Нет событий.
  2. Stage 1 — лог событий от планировщика.

    • Добавляем DomainEvents.
    • Планировщик после ключевых операций пишет туда события (для диагностики и аналитики).
    • Бизнес-сценарий не меняется.
  3. Stage 2 — ввод факта через события.

    • Внешние системы/диспетчер создают события форс-мажора в DomainEvents.
    • Пока планировщик не реагирует — всё по-старому, но данные копятся.
  4. Stage 3 — фоновый EventProcessor + локальный пересчет.

    • Добавляем RecalculateSchedule (локальный, минимально инвазивный).
    • EventProcessor:
      • читает новые события форс-мажора,
      • запускает RecalculateSchedule,
      • создаёт ScheduleRecomputedEvent.
    • На UI Ганта начинаем отображать результат пересчета (подсвеченные слоты).
  5. Stage 4 — улучшения:

    • история версий расписания,
    • расширение алгоритмов пересчета,
    • возможный переход к message-broker вместо чисто БД-логики.

6. Что здесь важно как «скелет» архитектуры

  • Domain-first модель:
    • Orders, Deliveries, Trucks, TruckShiftStates, Slots, Shifts — это твой инвариантный каркас.
  • События — только надстройка:
    • DomainEvents и EventProcessor можно подключать/усилять по мере роста зрелости.
  • Переход без Big Bang:
    • в любой момент ты можешь заморозить развитие на текущем Stage — система уже работает,
    • следующий шаг добавляет лишь реактивность и автоматизацию, не ломая базовый сценарий.

Полная, цельная, формальная архитектура:

  • Реляционную модель хранения (таблицы, связи, индексы, ограничения)
  • DDD‑агрегаты и репозитории
  • SQL‑констрейнты для непересечения слотов
  • Схему миграции от оффлайн‑планировщика к онлайн‑событийной системе
  • Архитектурный контур сервисов и поток данных
  • Сценарии отказоустойчивости и отката
  • Минимальный, но честный формат API
  • Схему интеграции с телематикой и UI

Это будет единый документ, который можно использовать как основу для проектирования системы.


✅ 1. Реляционная модель хранения (полная схема)

Ниже — полный набор таблиц, их связи, ключи, индексы и ограничения.


1.1. Таблица смен

Shifts

Поле Тип Описание
ShiftId (PK) UUID Идентификатор смены
StartTime datetime Начало смены
EndTime datetime Конец смены
Status enum Planned / InProgress / Completed / Cancelled
CreatedAt datetime  
UpdatedAt datetime  

Индексы:

  • idx_shifts_status (Status)

1.2. Клиенты и точки

Clients

Поле Тип
ClientId (PK) UUID
Name text
Address text
PriorityDefault enum
CreatedAt datetime
UpdatedAt datetime

Locations

Поле Тип
LocationId (PK) UUID
Type enum (Plant, ClientSite, Depot)
Name text
Address text
Latitude float
Longitude float
CreatedAt datetime
UpdatedAt datetime

1.3. Заказы

Orders

Поле Тип
OrderId (PK) UUID
ShiftId (FK) UUID
ClientId (FK) UUID
LoadPointId (FK) UUID
UnloadPointId (FK) UUID
TotalVolume float
ClientWindowStart datetime
ClientWindowEnd datetime
MaxAllowedDelay int
Priority enum
Status enum
CreatedAt datetime
UpdatedAt datetime

Индексы:

  • idx_orders_shift (ShiftId)
  • idx_orders_client (ClientId)

1.4. Машины и их состояние в смене

Trucks

Поле Тип
TruckId (PK) UUID
RegistrationNumber text
Capacity float
BaseLocationId (FK) UUID
IsActive bool
CreatedAt datetime
UpdatedAt datetime

TruckShiftStates

Поле Тип
TruckShiftStateId (PK) UUID
ShiftId (FK) UUID
TruckId (FK) UUID
InitialLocationId (FK) UUID
Status enum
AvailableFrom datetime
CurrentLocationId (FK) UUID
CreatedAt datetime
UpdatedAt datetime

Индексы:

  • idx_truckshift_shift_truck (ShiftId, TruckId)

1.5. Поставки

Deliveries

Поле Тип
DeliveryId (PK) UUID
OrderId (FK) UUID
ShiftId (FK) UUID
ClientId (FK) UUID
LoadPointId (FK) UUID
UnloadPointId (FK) UUID
Volume float
ClientWindowStart datetime
ClientWindowEnd datetime
MaxAllowedDelay int
Priority enum
TruckId (FK, nullable) UUID
SlotId (FK, nullable) UUID
PlannedUnloadTime datetime
ActualUnloadTime datetime
Status enum
IsAtRisk bool
CreatedAt datetime
UpdatedAt datetime

Индексы:

  • idx_deliveries_shift (ShiftId)
  • idx_deliveries_truck (TruckId)
  • idx_deliveries_order (OrderId)

1.6. Слоты (диаграмма Ганта)

Slots

Поле Тип
SlotId (PK) UUID
ShiftId (FK) UUID
TruckId (FK) UUID
DeliveryId (FK) UUID
StartTime datetime
EndTime datetime
LoadStartTime datetime
LoadEndTime datetime
TravelToClientStartTime datetime
TravelToClientEndTime datetime
UnloadStartTime datetime
UnloadEndTime datetime
ReturnStartTime datetime
ReturnEndTime datetime
MainEventTime datetime
Status enum
IsAffectedByRecalc bool
CreatedAt datetime
UpdatedAt datetime

Индексы:

  • idx_slots_shift_truck (ShiftId, TruckId)
  • idx_slots_delivery (DeliveryId)

✅ SQL‑ограничение: непересечение слотов

Реляционные БД не умеют нативно проверять пересечение интервалов, но можно:

Вариант 1 — EXCLUDE constraint (PostgreSQL)

ALTER TABLE Slots
ADD CONSTRAINT no_overlapping_slots
EXCLUDE USING GIST (
    TruckId WITH =,
    tstzrange(StartTime, EndTime) WITH &&
);

Вариант 2 — триггер BEFORE INSERT/UPDATE (универсальный)

CREATE FUNCTION check_slot_overlap() RETURNS trigger AS $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM Slots s
        WHERE s.TruckId = NEW.TruckId
          AND s.SlotId <> NEW.SlotId
          AND s.StartTime < NEW.EndTime
          AND s.EndTime > NEW.StartTime
    ) THEN
        RAISE EXCEPTION 'Slot overlaps with existing slot for truck %', NEW.TruckId;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_check_slot_overlap
BEFORE INSERT OR UPDATE ON Slots
FOR EACH ROW EXECUTE FUNCTION check_slot_overlap();

✅ 2. DDD‑агрегаты и репозитории


2.1. Агрегат ShiftSchedule

Корень агрегата: ShiftSchedule

Внутри:

  • список TruckSchedule
  • список Delivery
  • список Slot

Инварианты агрегата:

  • слоты одной машины не пересекаются,
  • каждая поставка имеет не более одного слота,
  • статус машины соответствует её слотам.

2.2. Агрегат TruckSchedule

Содержит:

  • TruckId
  • список Slots (отсортированных)
  • методы:
    • GetAvailableFrom()
    • Intersects(slot)
    • InsertSlot(slot)
    • RemoveSlot(slot)

2.3. Репозитории

IShiftScheduleRepository
    GetByShiftId(shiftId)
    Save(shiftSchedule)

IOrderRepository
    GetOrdersByShift(shiftId)
    Save(order)

IDeliveryRepository
    GetDeliveriesByShift(shiftId)
    Save(delivery)

ISlotRepository
    GetSlotsByShift(shiftId)
    Save(slot)

✅ 3. Архитектура сервисов


3.1. Сервисы домена

IOrderService

  • CreateOrder
  • SplitOrderIntoDeliveries
  • GetOrder
  • GetOrderDeliveries

ISchedulingService

  • BuildInitialSchedule
  • AssignDelivery
  • RecalculateSchedule
  • GetSchedule

ITruckPoolService

  • GetAvailableTrucks
  • SetTruckStatus
  • UpdateTruckAvailableFrom

IEventBus

  • Publish
  • Subscribe

✅ 4. Поток данных (Data Flow)


4.1. Первичное планирование

Orders → Split → Deliveries → AssignDelivery → Slots → ShiftSchedule

4.2. Онлайн‑обновление

Telematics / Dispatcher → DomainEvents → EventProcessor → RecalculateSchedule → Slots updated → UI refresh

✅ 5. Миграция от оффлайн к онлайн (пошагово)


Stage 0 — Оффлайн планировщик

  • BuildInitialSchedule
  • UI показывает Гант
  • Нет событий

Stage 1 — Добавляем DomainEvents (пассивно)

  • Планировщик пишет события
  • UI может показывать историю

Stage 2 — Ввод факта через события

  • Телематика пишет TruckBrokenEvent, UnloadDelayEvent
  • Планировщик пока не реагирует

Stage 3 — EventProcessor + локальный пересчёт

  • EventProcessor читает DomainEvents
  • вызывает RecalculateSchedule
  • пишет ScheduleRecomputedEvent
  • UI подсвечивает изменения

Stage 4 — Версионирование расписания

Добавляем таблицу:

ShiftSchedules

Поле Тип
ShiftScheduleId (PK) UUID
ShiftId UUID
Version int
CreatedAt datetime
CreatedBy text
IsActive bool

Все Slots и Deliveries привязываются к версии.


✅ 6. API (минимальный, но честный)


6.1. REST

GET /shifts/{id}/schedule

Возвращает активную версию расписания.

POST /shifts/{id}/recalculate

Запускает перерасчёт вручную.

POST /events

Создаёт событие (например, поломка).

GET /events?shiftId=…


✅ 7. Интеграция с телематикой

Телематика присылает:

  • GPS координаты → обновление CurrentLocationId
  • статус двигателя → TruckBrokenEvent
  • время прибытия/отъезда → DeliveryStartedEvent / DeliveryCompletedEvent
  • задержки → UnloadDelayOccurredEvent

Все события идут в DomainEvents.


✅ 8. Отказоустойчивость и откат


8.1. Версионирование расписания

  • каждая версия — immutable
  • откат = переключение IsActive = true на предыдущую версию

8.2. Идемпотентность событий

  • EventProcessor хранит ProcessedByPlanner
  • повторная обработка не приводит к дублированию слотов

8.3. Локальный пересчёт ограничен глубиной

  • не ломает всю смену
  • не создаёт каскадных цепочек

✅ 9. Полная архитектурная диаграмма (вербально)


Контуры:

  • UI диспетчера
  • Планировщик (SchedulingService)
  • EventProcessor
  • Telematics Adapter
  • База данных
  • EventBus

Потоки:

  1. UI → OrderService → Orders
  2. OrderService → Deliveries
  3. SchedulingService → Slots
  4. Telematics → DomainEvents
  5. EventProcessor → SchedulingService.Recalculate
  6. SchedulingService → Slots updated
  7. UI → обновление Ганта

✅ 10. Готово.

Это — полный, формальный, архитектурный документ, который можно использовать как основу для проектирования системы.

Полный набор UML‑диаграмм, которые описывают всю систему:

  • диаграмма классов (domain model),
  • диаграмма последовательности (primary scheduling),
  • диаграмма последовательности (каскадный пересчёт),
  • диаграмма компонентов (архитектура сервисов),
  • диаграмма состояний поставки,
  • диаграмма активности (алгоритм AssignDelivery).

Я оформлю их в текстовом UML‑стиле (PlantUML‑совместимом), чтобы ты мог сразу вставить в любой UML‑рендерер.


✅ 1. UML Диаграмма классов (Domain Model)

@startuml

class Shift {
    +ShiftId: UUID
    +StartTime: datetime
    +EndTime: datetime
    +Status: ShiftStatus
}

class Order {
    +OrderId: UUID
    +ShiftId: UUID
    +ClientId: UUID
    +LoadPointId: UUID
    +UnloadPointId: UUID
    +TotalVolume: float
    +ClientWindowStart: datetime
    +ClientWindowEnd: datetime
    +MaxAllowedDelay: int
    +Priority: Priority
    +Status: OrderStatus
}

class Delivery {
    +DeliveryId: UUID
    +OrderId: UUID
    +ShiftId: UUID
    +ClientId: UUID
    +LoadPointId: UUID
    +UnloadPointId: UUID
    +Volume: float
    +ClientWindowStart: datetime
    +ClientWindowEnd: datetime
    +Priority: Priority
    +TruckId: UUID?
    +SlotId: UUID?
    +PlannedUnloadTime: datetime?
    +ActualUnloadTime: datetime?
    +Status: DeliveryStatus
    +IsAtRisk: bool
}

class Truck {
    +TruckId: UUID
    +Capacity: float
    +BaseLocationId: UUID
    +IsActive: bool
}

class TruckShiftState {
    +TruckShiftStateId: UUID
    +ShiftId: UUID
    +TruckId: UUID
    +InitialLocationId: UUID
    +Status: TruckStatus
    +AvailableFrom: datetime
    +CurrentLocationId: UUID
}

class Slot {
    +SlotId: UUID
    +ShiftId: UUID
    +TruckId: UUID
    +DeliveryId: UUID
    +StartTime: datetime
    +EndTime: datetime
    +UnloadStartTime: datetime
    +UnloadEndTime: datetime
    +MainEventTime: datetime
    +Status: SlotStatus
    +IsAffectedByRecalc: bool
}

class DomainEvent {
    +EventId: UUID
    +EventType: string
    +OccurredAt: datetime
    +PayloadJson: string
}

Shift "1" -- "many" Order
Order "1" -- "many" Delivery
Shift "1" -- "many" Delivery
Truck "1" -- "many" Slot
Delivery "1" -- "1" Slot
Shift "1" -- "many" Slot
Truck "1" -- "1" TruckShiftState

@enduml

✅ 2. UML Диаграмма последовательности: первичное планирование смены

@startuml

actor Dispatcher
participant OrderService
participant SchedulingService
participant TruckPoolService
participant DeliveryRepository
participant SlotRepository

Dispatcher -> OrderService: CreateOrder()
OrderService -> DeliveryRepository: Save Order
OrderService -> DeliveryRepository: SplitOrderIntoDeliveries()

Dispatcher -> SchedulingService: BuildInitialSchedule(shiftId)

SchedulingService -> DeliveryRepository: GetDeliveriesByShift()
SchedulingService -> TruckPoolService: GetAvailableTrucks()

loop for each Delivery
    SchedulingService -> TruckPoolService: GetAvailableTrucks()
    SchedulingService -> SchedulingService: BuildSlotCandidate()
    SchedulingService -> SchedulingService: ScoreTrucks()
    SchedulingService -> SlotRepository: Save Slot
    SchedulingService -> DeliveryRepository: Update Delivery (TruckId, SlotId)
end

Dispatcher <-- SchedulingService: ShiftSchedule

@enduml

✅ 3. UML Диаграмма последовательности: каскадный перерасчёт при поломке

@startuml

actor Telematics
participant EventBus
participant EventProcessor
participant SchedulingService
participant SlotRepository
participant DeliveryRepository
participant TruckPoolService

Telematics -> EventBus: Publish(TruckBrokenEvent)

EventBus -> EventProcessor: NewEvent(TruckBrokenEvent)

EventProcessor -> SchedulingService: RecalculateSchedule(event)

SchedulingService -> SlotRepository: GetSlotsByTruck()
SchedulingService -> DeliveryRepository: Mark deliveries as Planned
SchedulingService -> TruckPoolService: SetTruckStatus(Broken)

loop for each affected delivery
    SchedulingService -> TruckPoolService: GetAvailableTrucks()
    SchedulingService -> SchedulingService: BuildSlotCandidate()
    alt success
        SchedulingService -> SlotRepository: Save new Slot
        SchedulingService -> DeliveryRepository: Update Delivery
    else fail
        SchedulingService -> DeliveryRepository: Mark IsAtRisk
    end
end

SchedulingService -> EventBus: Publish(ScheduleRecomputedEvent)

@enduml

✅ 4. UML Диаграмма компонентов (архитектура сервисов)

@startuml

package "Application Layer" {
    [OrderService]
    [SchedulingService]
    [TruckPoolService]
    [EventProcessor]
}

package "Domain Layer" {
    [ShiftSchedule Aggregate]
    [TruckSchedule]
    [Delivery]
    [Slot]
}

package "Infrastructure Layer" {
    [SQL Database]
    [EventBus]
    [Telematics Adapter]
}

OrderService --> SQL Database
SchedulingService --> SQL Database
TruckPoolService --> SQL Database

EventProcessor --> SQL Database
EventProcessor --> SchedulingService

Telematics Adapter --> EventBus
EventBus --> EventProcessor

SchedulingService --> EventBus

@enduml

✅ 5. UML Диаграмма состояний поставки

@startuml

[*] --> Planned

Planned --> Assigned : Slot assigned
Assigned --> InTransit : DeliveryStartedEvent
InTransit --> Unloading : Arrived at client
Unloading --> Completed : DeliveryCompletedEvent

Assigned --> Failed : No truck available
InTransit --> Failed : Accident / Breakdown
Unloading --> Failed : Unload impossible

Completed --> [*]
Failed --> [*]

@enduml

✅ 6. UML Диаграмма активности: алгоритм AssignDelivery

@startuml

start

:Load delivery;
:Query available trucks;

if (no trucks?) then (yes)
    :Return failure;
    stop
endif

:For each truck:
    :BuildSlotCandidate();
    if (candidate valid?) then (yes)
        :Compute score;
        :Update best candidate;
    endif
endfor

if (best candidate exists?) then (no)
    :Return failure;
    stop
endif

:Save Slot;
:Update Delivery;
:Publish DeliveryPlannedEvent;

stop

@enduml

Ниже — минимальный, но честный прототип на C#, который отражает логику:

  • доменные сущности (упрощённые),
  • построение слота для машины,
  • выбор лучшего грузовика,
  • AssignDelivery,
  • первичное построение расписания на смену.

Без БД и инфраструктуры, просто in‑memory, но по структуре близко к тому, что можно обрастить репозиториями.


1. Доменные enum’ы и простые value‑объекты

public enum Priority
{
    Low,
    Normal,
    High,
    Critical
}

public enum DeliveryStatus
{
    Planned,
    Assigned,
    InTransit,
    Unloading,
    Completed,
    Cancelled,
    Failed
}

public enum SlotStatus
{
    Planned,
    InProgress,
    Completed,
    Cancelled,
    Reassigned
}

public enum TruckStatus
{
    Available,
    Busy,
    Broken,
    OutOfService
}

public record TimeWindow(DateTime Start, DateTime End);

2. Базовые сущности: Truck, Delivery, Slot

public class Truck
{
    public string TruckId { get; init; } = default!;
    public double Capacity { get; init; }  // m3
    public string BaseLocationId { get; init; } = default!;

    // Для прототипа: просто статус и момент, с которого доступен
    public TruckStatus Status { get; set; } = TruckStatus.Available;
    public DateTime AvailableFrom { get; set; }
    public string CurrentLocationId { get; set; } = default!;
}

public class Delivery
{
    public string DeliveryId { get; init; } = default!;
    public string OrderId { get; init; } = default!;
    public string ClientId { get; init; } = default!;
    public string LoadPointId { get; init; } = default!;
    public string UnloadPointId { get; init; } = default!;
    public double Volume { get; init; }

    public DateTime ClientWindowStart { get; init; }
    public DateTime ClientWindowEnd   { get; init; }
    public TimeSpan MaxAllowedDelay   { get; init; }
    public Priority Priority          { get; init; }

    public string? TruckId { get; set; }
    public string? SlotId  { get; set; }

    public DateTime? PlannedUnloadTime { get; set; }
    public DateTime? ActualUnloadTime  { get; set; }

    public DeliveryStatus Status { get; set; } = DeliveryStatus.Planned;
    public bool IsAtRisk { get; set; } = false;
}

public class Slot
{
    public string SlotId { get; init; } = Guid.NewGuid().ToString();
    public string ShiftId { get; init; } = default!;
    public string TruckId { get; init; } = default!;
    public string DeliveryId { get; init; } = default!;

    public DateTime StartTime { get; set; }
    public DateTime EndTime   { get; set; }

    public DateTime LoadStartTime { get; set; }
    public DateTime LoadEndTime   { get; set; }

    public DateTime TravelToClientStartTime { get; set; }
    public DateTime TravelToClientEndTime   { get; set; }

    public DateTime UnloadStartTime { get; set; }
    public DateTime UnloadEndTime   { get; set; }

    public DateTime ReturnStartTime { get; set; }
    public DateTime ReturnEndTime   { get; set; }

    public DateTime MainEventTime   { get; set; }

    public SlotStatus Status { get; set; } = SlotStatus.Planned;
    public bool IsAffectedByRecalc { get; set; } = false;
}

3. Расписание смены и машины

public class TruckSchedule
{
    public string ShiftId { get; }
    public string TruckId { get; }
    public List<Slot> Slots { get; } = new();

    public TruckSchedule(string shiftId, string truckId)
    {
        ShiftId = shiftId;
        TruckId = truckId;
    }

    public DateTime GetAvailableFrom(DateTime shiftStart)
    {
        if (Slots.Count == 0) return shiftStart;
        return Slots.Max(s => s.EndTime);
    }

    public bool Intersects(Slot candidate)
    {
        return Slots.Any(s =>
            s.StartTime < candidate.EndTime &&
            s.EndTime   > candidate.StartTime);
    }

    public void AddSlot(Slot slot)
    {
        Slots.Add(slot);
        Slots.Sort((a, b) => a.StartTime.CompareTo(b.StartTime));
    }
}

public class ShiftSchedule
{
    public string ShiftId { get; }
    public DateTime ShiftStart { get; }
    public DateTime ShiftEnd   { get; }

    public List<TruckSchedule> TruckSchedules { get; } = new();
    public List<Delivery> Deliveries         { get; } = new();

    public ShiftSchedule(string shiftId, DateTime shiftStart, DateTime shiftEnd)
    {
        ShiftId = shiftId;
        ShiftStart = shiftStart;
        ShiftEnd = shiftEnd;
    }

    public TruckSchedule GetTruckSchedule(string truckId)
    {
        var ts = TruckSchedules.FirstOrDefault(t => t.TruckId == truckId);
        if (ts == null)
        {
            ts = new TruckSchedule(ShiftId, truckId);
            TruckSchedules.Add(ts);
        }
        return ts;
    }
}

4. Интерфейс для TravelTimes (время движения, погрузки, разгрузки, буфер)

public interface ITravelTimeProvider
{
    TimeSpan GetTravelTime(string fromLocationId, string toLocationId);
    TimeSpan GetLoadDuration(Delivery delivery);
    TimeSpan GetUnloadDuration(Delivery delivery);

    // Буфер на форы-мажор, можно сделать простым коэффициентом
    TimeSpan GetUncertaintyBuffer(Delivery delivery);
}

Для прототипа можно сделать фиктивную реализацию:

public class SimpleTravelTimeProvider : ITravelTimeProvider
{
    public TimeSpan GetTravelTime(string fromLocationId, string toLocationId)
    {
        // Упрощённо: всё 30 минут
        return TimeSpan.FromMinutes(30);
    }

    public TimeSpan GetLoadDuration(Delivery delivery)
    {
        // Допустим, 10 минут на любую поставку
        return TimeSpan.FromMinutes(10);
    }

    public TimeSpan GetUnloadDuration(Delivery delivery)
    {
        // Допустим, 15 минут на разгрузку
        return TimeSpan.FromMinutes(15);
    }

    public TimeSpan GetUncertaintyBuffer(Delivery delivery)
    {
        // 10% от цикла или фиксированно, допустим 10 минут
        return TimeSpan.FromMinutes(10);
    }
}

5. Сервис выбора машины и построения слота

public class SchedulingService
{
    private readonly ITravelTimeProvider _travelTimes;

    public SchedulingService(ITravelTimeProvider travelTimes)
    {
        _travelTimes = travelTimes;
    }

    public class AssignmentResult
    {
        public bool Success { get; init; }
        public string? TruckId { get; init; }
        public string? SlotId  { get; init; }
        public string? ReasonIfFailed { get; init; }
    }

    public AssignmentResult AssignDelivery(
        ShiftSchedule schedule,
        Delivery delivery,
        IReadOnlyList<Truck> allTrucks)
    {
        // Фильтрация по вместимости и статусу
        var candidateTrucks = allTrucks
            .Where(t => t.Status == TruckStatus.Available &&
                        t.Capacity >= delivery.Volume)
            .ToList();

        if (!candidateTrucks.Any())
        {
            return new AssignmentResult
            {
                Success = false,
                ReasonIfFailed = "No trucks with sufficient capacity and available status"
            };
        }

        double bestScore = double.PositiveInfinity;
        Truck? bestTruck = null;
        Slot? bestSlot = null;

        foreach (var truck in candidateTrucks)
        {
            var ts = schedule.GetTruckSchedule(truck.TruckId);
            var slotCandidate = BuildSlotCandidate(schedule, ts, truck, delivery);
            if (slotCandidate == null)
                continue;

            var score = ScoreTruckForDelivery(truck, ts, delivery, slotCandidate);
            if (score < bestScore)
            {
                bestScore = score;
                bestTruck = truck;
                bestSlot  = slotCandidate;
            }
        }

        if (bestTruck == null || bestSlot == null)
        {
            return new AssignmentResult
            {
                Success = false,
                ReasonIfFailed = "No feasible slot for any candidate truck"
            };
        }

        // Фиксируем результат
        schedule.GetTruckSchedule(bestTruck.TruckId).AddSlot(bestSlot);

        delivery.TruckId = bestTruck.TruckId;
        delivery.SlotId  = bestSlot.SlotId;
        delivery.PlannedUnloadTime = bestSlot.UnloadStartTime;
        delivery.Status = DeliveryStatus.Assigned;

        return new AssignmentResult
        {
            Success = true,
            TruckId = bestTruck.TruckId,
            SlotId  = bestSlot.SlotId
        };
    }

    private Slot? BuildSlotCandidate(
        ShiftSchedule schedule,
        TruckSchedule truckSchedule,
        Truck truck,
        Delivery delivery)
    {
        var availableFrom = truckSchedule.GetAvailableFrom(schedule.ShiftStart);

        // Время доехать от текущей позиции к точке погрузки
        var travelToLoad = _travelTimes.GetTravelTime(truck.CurrentLocationId, delivery.LoadPointId);

        var loadStart = availableFrom + travelToLoad;
        var loadEnd   = loadStart + _travelTimes.GetLoadDuration(delivery);

        var toClientStart = loadEnd;
        var toClientEnd   = toClientStart + _travelTimes.GetTravelTime(delivery.LoadPointId, delivery.UnloadPointId);

        var unloadStart = toClientEnd;
        var unloadEnd   = unloadStart + _travelTimes.GetUnloadDuration(delivery);

        var returnStart = unloadEnd;
        var returnEnd   = returnStart + _travelTimes.GetTravelTime(delivery.UnloadPointId, truck.BaseLocationId);

        // Буфер
        var buffer = _travelTimes.GetUncertaintyBuffer(delivery);
        returnEnd = returnEnd + buffer;

        // Проверка окна клиента (разгрузка не должна сильно выпадать)
        if (unloadStart > delivery.ClientWindowEnd + delivery.MaxAllowedDelay)
            return null;

        // Проверка границ смены
        if (returnEnd > schedule.ShiftEnd)
            return null;

        var slot = new Slot
        {
            ShiftId = schedule.ShiftId,
            TruckId = truck.TruckId,
            DeliveryId = delivery.DeliveryId,

            StartTime = loadStart,
            EndTime   = returnEnd,

            LoadStartTime = loadStart,
            LoadEndTime   = loadEnd,

            TravelToClientStartTime = toClientStart,
            TravelToClientEndTime   = toClientEnd,

            UnloadStartTime = unloadStart,
            UnloadEndTime   = unloadEnd,

            ReturnStartTime = returnStart,
            ReturnEndTime   = returnEnd,

            MainEventTime   = unloadStart,
            Status = SlotStatus.Planned
        };

        // Проверка пересечения слотов на этой машине
        if (truckSchedule.Intersects(slot))
            return null;

        return slot;
    }

    private double ScoreTruckForDelivery(
        Truck truck,
        TruckSchedule truckSchedule,
        Delivery delivery,
        Slot slotCandidate)
    {
        // Для прототипа: простой score: позже разгрузка и больше "слэк" — хуже.
        // Можно добавить расстояние, приоритет и т.п.

        var availableFrom = truckSchedule.GetAvailableFrom(DateTime.MinValue);
        var slack = (slotCandidate.LoadStartTime - availableFrom).TotalMinutes;

        double windowPenalty = 0;
        if (slotCandidate.UnloadStartTime < delivery.ClientWindowStart)
        {
            windowPenalty = (delivery.ClientWindowStart - slotCandidate.UnloadStartTime).TotalMinutes;
        }
        else if (slotCandidate.UnloadStartTime > delivery.ClientWindowEnd)
        {
            windowPenalty = (slotCandidate.UnloadStartTime - delivery.ClientWindowEnd).TotalMinutes;
        }

        double priorityFactor = delivery.Priority switch
        {
            Priority.Critical => 0.5,
            Priority.High     => 0.8,
            Priority.Normal   => 1.0,
            Priority.Low      => 1.2,
            _                 => 1.0
        };

        const double wSlack  = 1.0;
        const double wWindow = 2.0;

        var score = priorityFactor * (wSlack * slack + wWindow * windowPenalty);

        return score;
    }
}

6. Построение начального расписания смены

public class ShiftPlanner
{
    private readonly SchedulingService _schedulingService;

    public ShiftPlanner(SchedulingService schedulingService)
    {
        _schedulingService = schedulingService;
    }

    public ShiftSchedule BuildInitialSchedule(
        string shiftId,
        DateTime shiftStart,
        DateTime shiftEnd,
        List<Delivery> deliveries,
        List<Truck> trucks)
    {
        var schedule = new ShiftSchedule(shiftId, shiftStart, shiftEnd);
        schedule.Deliveries.AddRange(deliveries);

        // Инициализируем пустые расписания машин
        foreach (var truck in trucks)
        {
            schedule.TruckSchedules.Add(new TruckSchedule(shiftId, truck.TruckId));
        }

        // Сортировка поставок по приоритету и раннему дедлайну
        var orderedDeliveries = deliveries
            .OrderByDescending(d => d.Priority)
            .ThenBy(d => d.ClientWindowEnd)
            .ToList();

        foreach (var delivery in orderedDeliveries)
        {
            var result = _schedulingService.AssignDelivery(schedule, delivery, trucks);
            if (!result.Success)
            {
                delivery.IsAtRisk = true;
                delivery.Status = DeliveryStatus.Planned; // но в риске
                // Для прототипа просто помечаем, в реале — лог/событие
            }
        }

        return schedule;
    }
}

7. Пример вызова (мини‑демо)

public static class Demo
{
    public static void Run()
    {
        var shiftId = "shift-001";
        var shiftStart = DateTime.Today.AddHours(8);   // 8:00
        var shiftEnd   = DateTime.Today.AddHours(20);  // 20:00

        var trucks = new List<Truck>
        {
            new Truck
            {
                TruckId = "T1",
                Capacity = 5,
                BaseLocationId = "PLANT1",
                Status = TruckStatus.Available,
                AvailableFrom = shiftStart,
                CurrentLocationId = "PLANT1"
            },
            new Truck
            {
                TruckId = "T2",
                Capacity = 7,
                BaseLocationId = "PLANT1",
                Status = TruckStatus.Available,
                AvailableFrom = shiftStart,
                CurrentLocationId = "PLANT1"
            }
        };

        var deliveries = new List<Delivery>
        {
            new Delivery
            {
                DeliveryId = "D1",
                OrderId = "O1",
                ClientId = "C1",
                LoadPointId = "PLANT1",
                UnloadPointId = "SITE1",
                Volume = 5,
                ClientWindowStart = shiftStart.AddHours(1),
                ClientWindowEnd   = shiftStart.AddHours(4),
                MaxAllowedDelay   = TimeSpan.FromMinutes(30),
                Priority          = Priority.High
            },
            new Delivery
            {
                DeliveryId = "D2",
                OrderId = "O1",
                ClientId = "C1",
                LoadPointId = "PLANT1",
                UnloadPointId = "SITE1",
                Volume = 5,
                ClientWindowStart = shiftStart.AddHours(2),
                ClientWindowEnd   = shiftStart.AddHours(6),
                MaxAllowedDelay   = TimeSpan.FromMinutes(30),
                Priority          = Priority.High
            },
            new Delivery
            {
                DeliveryId = "D3",
                OrderId = "O2",
                ClientId = "C2",
                LoadPointId = "PLANT1",
                UnloadPointId = "SITE2",
                Volume = 7,
                ClientWindowStart = shiftStart.AddHours(3),
                ClientWindowEnd   = shiftStart.AddHours(7),
                MaxAllowedDelay   = TimeSpan.FromMinutes(30),
                Priority          = Priority.Normal
            }
        };

        var travelProvider = new SimpleTravelTimeProvider();
        var schedulingService = new SchedulingService(travelProvider);
        var planner = new ShiftPlanner(schedulingService);

        var schedule = planner.BuildInitialSchedule(
            shiftId,
            shiftStart,
            shiftEnd,
            deliveries,
            trucks);

        // Примитивный вывод результата
        foreach (var ts in schedule.TruckSchedules)
        {
            Console.WriteLine($"Truck {ts.TruckId}:");
            foreach (var slot in ts.Slots.OrderBy(s => s.StartTime))
            {
                Console.WriteLine($"  Delivery {slot.DeliveryId}: {slot.StartTime:t} - {slot.EndTime:t}, unload at {slot.UnloadStartTime:t}");
            }
        }

        Console.WriteLine("Deliveries:");
        foreach (var d in schedule.Deliveries)
        {
            Console.WriteLine($"  {d.DeliveryId}: Truck={d.TruckId}, Status={d.Status}, IsAtRisk={d.IsAtRisk}");
        }
    }
}

Ниже соберу всё в один цельный прототип:

  • обработчик форс‑мажора (поломка + каскадный пересчёт),
  • минимальные репозитории (интерфейсы + in‑memory),
  • простой application‑слой (Application → Domain → Infrastructure),
  • задел под реальный стек (EF/Dapper, фоновые джобы, лог событий).

Я буду продолжать на том же упрощённом домене, что и раньше, чтобы код оставался читаемым.


1. Минимальные интерфейсы репозиториев (Domain / Application)

public interface ITruckRepository
{
    Truck? GetById(string truckId);
    IReadOnlyList<Truck> GetAllActive();
    void Save(Truck truck);
}

public interface IDeliveryRepository
{
    Delivery? GetById(string deliveryId);
    IReadOnlyList<Delivery> GetByShift(string shiftId);
    void Save(Delivery delivery);
}

public interface ISlotRepository
{
    IReadOnlyList<Slot> GetByShift(string shiftId);
    IReadOnlyList<Slot> GetByTruck(string shiftId, string truckId);
    void Save(Slot slot);
    void Delete(Slot slot);
}

public interface IShiftScheduleRepository
{
    ShiftSchedule GetByShift(string shiftId);
    void Save(ShiftSchedule schedule);
}

2. Простые in‑memory реализации (Infrastructure mock)

public class InMemoryTruckRepository : ITruckRepository
{
    private readonly Dictionary<string, Truck> _trucks = new();

    public Truck? GetById(string truckId)
        => _trucks.TryGetValue(truckId, out var t) ? t : null;

    public IReadOnlyList<Truck> GetAllActive()
        => _trucks.Values.Where(t => t.IsActive).ToList();

    public void Save(Truck truck)
        => _trucks[truck.TruckId] = truck;
}

public class InMemoryDeliveryRepository : IDeliveryRepository
{
    private readonly Dictionary<string, Delivery> _deliveries = new();

    public Delivery? GetById(string deliveryId)
        => _deliveries.TryGetValue(deliveryId, out var d) ? d : null;

    public IReadOnlyList<Delivery> GetByShift(string shiftId)
        => _deliveries.Values.Where(d => d.ShiftId == shiftId).ToList();

    public void Save(Delivery delivery)
        => _deliveries[delivery.DeliveryId] = delivery;
}

public class InMemorySlotRepository : ISlotRepository
{
    private readonly List<Slot> _slots = new();

    public IReadOnlyList<Slot> GetByShift(string shiftId)
        => _slots.Where(s => s.ShiftId == shiftId).ToList();

    public IReadOnlyList<Slot> GetByTruck(string shiftId, string truckId)
        => _slots.Where(s => s.ShiftId == shiftId && s.TruckId == truckId).ToList();

    public void Save(Slot slot)
    {
        var existing = _slots.FirstOrDefault(s => s.SlotId == slot.SlotId);
        if (existing != null)
        {
            _slots.Remove(existing);
        }
        _slots.Add(slot);
    }

    public void Delete(Slot slot)
        => _slots.RemoveAll(s => s.SlotId == slot.SlotId);
}

public class InMemoryShiftScheduleRepository : IShiftScheduleRepository
{
    private readonly ISlotRepository _slotRepo;
    private readonly IDeliveryRepository _deliveryRepo;
    private readonly ITruckRepository _truckRepo;

    public InMemoryShiftScheduleRepository(
        ISlotRepository slotRepo,
        IDeliveryRepository deliveryRepo,
        ITruckRepository truckRepo)
    {
        _slotRepo = slotRepo;
        _deliveryRepo = deliveryRepo;
        _truckRepo = truckRepo;
    }

    public ShiftSchedule GetByShift(string shiftId)
    {
        // В реале тут было бы чтение из БД + агрегация.
        // Здесь — просто собираем из in-memory.
        var deliveries = _deliveryRepo.GetByShift(shiftId).ToList();
        var slots = _slotRepo.GetByShift(shiftId).ToList();
        var trucks = _truckRepo.GetAllActive();

        // Берем границы смены по факту минимального/максимального времени слотов
        var shiftStart = deliveries.Select(d => d.ClientWindowStart).DefaultIfEmpty(DateTime.Today).Min();
        var shiftEnd   = deliveries.Select(d => d.ClientWindowEnd).DefaultIfEmpty(DateTime.Today.AddHours(12)).Max();

        var schedule = new ShiftSchedule(shiftId, shiftStart, shiftEnd);
        schedule.Deliveries.AddRange(deliveries);

        foreach (var truck in trucks)
        {
            var ts = new TruckSchedule(shiftId, truck.TruckId);
            foreach (var slot in slots.Where(s => s.TruckId == truck.TruckId))
            {
                ts.AddSlot(slot);
            }
            schedule.TruckSchedules.Add(ts);
        }

        return schedule;
    }

    public void Save(ShiftSchedule schedule)
    {
        // Сохраняем deliveries и slots в in-memory репозитории
        foreach (var d in schedule.Deliveries)
            _deliveryRepo.Save(d);

        foreach (var ts in schedule.TruckSchedules)
        {
            foreach (var s in ts.Slots)
                _slotRepo.Save(s);
        }
    }
}

3. События и EventBus (упрощённо)

public abstract class DomainEvent
{
    public string EventId { get; } = Guid.NewGuid().ToString();
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
}

public class TruckBrokenEvent : DomainEvent
{
    public string ShiftId { get; init; } = default!;
    public string TruckId { get; init; } = default!;
    public DateTime BreakdownTime { get; init; }
    public DateTime? ExpectedBackOnline { get; init; }
}

public class UnloadDelayOccurredEvent : DomainEvent
{
    public string ShiftId { get; init; } = default!;
    public string DeliveryId { get; init; } = default!;
    public DateTime NewExpectedUnloadEndTime { get; init; }
}

public interface IEventBus
{
    void Publish(DomainEvent evt);
    void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : DomainEvent;
}

public class InMemoryEventBus : IEventBus
{
    private readonly Dictionary<Type, List<Delegate>> _handlers = new();

    public void Publish(DomainEvent evt)
    {
        var type = evt.GetType();
        if (_handlers.TryGetValue(type, out var handlers))
        {
            foreach (var handler in handlers)
            {
                handler.DynamicInvoke(evt);
            }
        }
    }

    public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : DomainEvent
    {
        var type = typeof(TEvent);
        if (!_handlers.ContainsKey(type))
        {
            _handlers[type] = new List<Delegate>();
        }
        _handlers[type].Add(handler);
    }
}

4. Обработчик форс‑мажора: каскадный пересчёт

Добавим сервис ScheduleRecalcService, который использует SchedulingService и репозитории.

public class ScheduleRecalcService
{
    private readonly SchedulingService _schedulingService;
    private readonly IShiftScheduleRepository _scheduleRepository;
    private readonly ITruckRepository _truckRepository;
    private readonly IDeliveryRepository _deliveryRepository;
    private readonly ISlotRepository _slotRepository;

    public ScheduleRecalcService(
        SchedulingService schedulingService,
        IShiftScheduleRepository scheduleRepository,
        ITruckRepository truckRepository,
        IDeliveryRepository deliveryRepository,
        ISlotRepository slotRepository)
    {
        _schedulingService = schedulingService;
        _scheduleRepository = scheduleRepository;
        _truckRepository = truckRepository;
        _deliveryRepository = deliveryRepository;
        _slotRepository = slotRepository;
    }

    public void HandleTruckBroken(TruckBrokenEvent evt)
    {
        var schedule = _scheduleRepository.GetByShift(evt.ShiftId);

        var truck = _truckRepository.GetById(evt.TruckId);
        if (truck == null) return;

        truck.Status = TruckStatus.Broken;
        truck.AvailableFrom = evt.ExpectedBackOnline ?? schedule.ShiftEnd;
        _truckRepository.Save(truck);

        var truckSchedule = schedule.GetTruckSchedule(truck.TruckId);

        // все слоты, начинающиеся после момента поломки
        var futureSlots = truckSchedule.Slots
            .Where(s => s.StartTime >= evt.BreakdownTime)
            .ToList();

        foreach (var slot in futureSlots)
        {
            // снять слот
            truckSchedule.Slots.Remove(slot);
            _slotRepository.Delete(slot);

            var delivery = schedule.Deliveries.FirstOrDefault(d => d.DeliveryId == slot.DeliveryId);
            if (delivery == null) continue;

            delivery.TruckId = null;
            delivery.SlotId = null;
            delivery.Status = DeliveryStatus.Planned;

            var allTrucks = _truckRepository.GetAllActive();
            var result = _schedulingService.AssignDelivery(schedule, delivery, allTrucks);

            if (!result.Success)
            {
                delivery.IsAtRisk = true;
            }

            _deliveryRepository.Save(delivery);
        }

        // Сохранить обновлённое расписание
        _scheduleRepository.Save(schedule);
    }

    public void HandleUnloadDelay(UnloadDelayOccurredEvent evt)
    {
        var schedule = _scheduleRepository.GetByShift(evt.ShiftId);

        var delivery = schedule.Deliveries.FirstOrDefault(d => d.DeliveryId == evt.DeliveryId);
        if (delivery == null) return;

        if (delivery.TruckId == null || delivery.SlotId == null) return;

        var truckSchedule = schedule.GetTruckSchedule(delivery.TruckId);
        var currentSlot = truckSchedule.Slots.FirstOrDefault(s => s.SlotId == delivery.SlotId);
        if (currentSlot == null) return;

        // Обновляем текущий слот
        var delay = evt.NewExpectedUnloadEndTime - currentSlot.UnloadEndTime;
        currentSlot.UnloadEndTime = evt.NewExpectedUnloadEndTime;
        currentSlot.ReturnStartTime = currentSlot.UnloadEndTime;
        currentSlot.ReturnEndTime = currentSlot.ReturnEndTime + delay;
        currentSlot.EndTime = currentSlot.ReturnEndTime;

        // Находим последующие слоты, которые теперь пересекаются
        var futureSlots = truckSchedule.Slots
            .Where(s => s.StartTime < currentSlot.EndTime &&
                        s.StartTime > currentSlot.StartTime)
            .ToList();

        foreach (var slot in futureSlots)
        {
            truckSchedule.Slots.Remove(slot);
            _slotRepository.Delete(slot);

            var d = schedule.Deliveries.FirstOrDefault(x => x.DeliveryId == slot.DeliveryId);
            if (d == null) continue;

            d.TruckId = null;
            d.SlotId = null;
            d.Status = DeliveryStatus.Planned;

            var allTrucks = _truckRepository.GetAllActive();
            var result = _schedulingService.AssignDelivery(schedule, d, allTrucks);

            if (!result.Success)
            {
                d.IsAtRisk = true;
            }

            _deliveryRepository.Save(d);
        }

        _slotRepository.Save(currentSlot);
        _scheduleRepository.Save(schedule);
    }
}

5. Обвязка Application → Domain → Infrastructure + EventBus

Соберём всё вместе в мини‑композиции:

public static class AppComposition
{
    public static void RunDemo()
    {
        // Инфраструктура
        var truckRepo = new InMemoryTruckRepository();
        var deliveryRepo = new InMemoryDeliveryRepository();
        var slotRepo = new InMemorySlotRepository();
        var scheduleRepo = new InMemoryShiftScheduleRepository(slotRepo, deliveryRepo, truckRepo);
        var eventBus = new InMemoryEventBus();

        // Доменные сервисы
        var travelProvider = new SimpleTravelTimeProvider();
        var schedulingService = new SchedulingService(travelProvider);
        var recalcService = new ScheduleRecalcService(
            schedulingService,
            scheduleRepo,
            truckRepo,
            deliveryRepo,
            slotRepo);

        // Подписки на события
        eventBus.Subscribe<TruckBrokenEvent>(recalcService.HandleTruckBroken);
        eventBus.Subscribe<UnloadDelayOccurredEvent>(recalcService.HandleUnloadDelay);

        // Данные для демо
        var shiftId = "shift-001";
        var shiftStart = DateTime.Today.AddHours(8);
        var shiftEnd   = DateTime.Today.AddHours(20);

        var trucks = new List<Truck>
        {
            new Truck
            {
                TruckId = "T1",
                Capacity = 5,
                BaseLocationId = "PLANT1",
                Status = TruckStatus.Available,
                AvailableFrom = shiftStart,
                CurrentLocationId = "PLANT1",
                IsActive = true
            },
            new Truck
            {
                TruckId = "T2",
                Capacity = 7,
                BaseLocationId = "PLANT1",
                Status = TruckStatus.Available,
                AvailableFrom = shiftStart,
                CurrentLocationId = "PLANT1",
                IsActive = true
            }
        };

        foreach (var t in trucks)
            truckRepo.Save(t);

        var deliveries = new List<Delivery>
        {
            new Delivery
            {
                DeliveryId = "D1",
                OrderId = "O1",
                ShiftId = shiftId,
                ClientId = "C1",
                LoadPointId = "PLANT1",
                UnloadPointId = "SITE1",
                Volume = 5,
                ClientWindowStart = shiftStart.AddHours(1),
                ClientWindowEnd   = shiftStart.AddHours(4),
                MaxAllowedDelay   = TimeSpan.FromMinutes(30),
                Priority          = Priority.High
            },
            new Delivery
            {
                DeliveryId = "D2",
                OrderId = "O1",
                ShiftId = shiftId,
                ClientId = "C1",
                LoadPointId = "PLANT1",
                UnloadPointId = "SITE1",
                Volume = 5,
                ClientWindowStart = shiftStart.AddHours(2),
                ClientWindowEnd   = shiftStart.AddHours(6),
                MaxAllowedDelay   = TimeSpan.FromMinutes(30),
                Priority          = Priority.High
            },
            new Delivery
            {
                DeliveryId = "D3",
                OrderId = "O2",
                ShiftId = shiftId,
                ClientId = "C2",
                LoadPointId = "PLANT1",
                UnloadPointId = "SITE2",
                Volume = 7,
                ClientWindowStart = shiftStart.AddHours(3),
                ClientWindowEnd   = shiftStart.AddHours(7),
                MaxAllowedDelay   = TimeSpan.FromMinutes(30),
                Priority          = Priority.Normal
            }
        };

        foreach (var d in deliveries)
            deliveryRepo.Save(d);

        // Первичное планирование
        var planner = new ShiftPlanner(schedulingService);
        var schedule = planner.BuildInitialSchedule(
            shiftId,
            shiftStart,
            shiftEnd,
            deliveries,
            trucks);

        scheduleRepo.Save(schedule);

        Console.WriteLine("=== Initial schedule ===");
        PrintSchedule(schedule);

        // Симулируем форс‑мажор: поломка T1 в середине смены
        var breakdownEvent = new TruckBrokenEvent
        {
            ShiftId = shiftId,
            TruckId = "T1",
            BreakdownTime = shiftStart.AddHours(5),       // например, 13:00
            ExpectedBackOnline = null                     // до конца смены не вернётся
        };

        eventBus.Publish(breakdownEvent);

        var updatedSchedule = scheduleRepo.GetByShift(shiftId);

        Console.WriteLine();
        Console.WriteLine("=== Schedule after Truck T1 breakdown ===");
        PrintSchedule(updatedSchedule);
    }

    private static void PrintSchedule(ShiftSchedule schedule)
    {
        foreach (var ts in schedule.TruckSchedules)
        {
            Console.WriteLine($"Truck {ts.TruckId}:");
            foreach (var slot in ts.Slots.OrderBy(s => s.StartTime))
            {
                Console.WriteLine($"  Delivery {slot.DeliveryId}: {slot.StartTime:t} - {slot.EndTime:t}, unload at {slot.UnloadStartTime:t}");
            }
        }

        Console.WriteLine("Deliveries:");
        foreach (var d in schedule.Deliveries.OrderBy(d => d.DeliveryId))
        {
            Console.WriteLine($"  {d.DeliveryId}: Truck={d.TruckId}, Status={d.Status}, IsAtRisk={d.IsAtRisk}");
        }
    }
}

6. Как это переносится на реальный стек (намётки)

  • InMemory*Repository → классы с EF/Dapper (DbContext/IDbConnection).
  • InMemoryEventBus → адаптер к Kafka/RabbitMQ/ServiceBus.
  • ScheduleRecalcService → фонова задача (HostedService / Hangfire job), подписывающаяся на реальные события.
  • ShiftScheduleRepository → агрегирующее чтение из нескольких таблиц (Slots, Deliveries, Trucks).

EF Core‑модель и Web API‑обвязку — в едином стиле, как реальный production‑код, который можно взять и развивать.

  1. EF Core DbContext + Fluent Configuration
  2. Entity‑классы, адаптированные под EF
  3. Web API (ASP.NET Core) контроллеры
  4. DTO + маппинг (ручной или AutoMapper)
  5. Сервисный слой (Application Layer)
  6. Пайплайн: Controller → Application Service → Domain → Repositories → EF

✅ 1. EF Core модель: DbContext + Fluent API

1.1. DbContext

public class LogisticsDbContext : DbContext
{
    public DbSet<Shift> Shifts => Set<Shift>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Delivery> Deliveries => Set<Delivery>();
    public DbSet<Truck> Trucks => Set<Truck>();
    public DbSet<TruckShiftState> TruckShiftStates => Set<TruckShiftState>();
    public DbSet<Slot> Slots => Set<Slot>();
    public DbSet<DomainEventEntity> DomainEvents => Set<DomainEventEntity>();

    public LogisticsDbContext(DbContextOptions<LogisticsDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(LogisticsDbContext).Assembly);
    }
}

✅ 2. Fluent Configuration для сущностей

2.1. Shift

public class ShiftConfiguration : IEntityTypeConfiguration<Shift>
{
    public void Configure(EntityTypeBuilder<Shift> builder)
    {
        builder.ToTable("Shifts");
        builder.HasKey(x => x.ShiftId);

        builder.Property(x => x.Status)
            .HasConversion<string>();
    }
}

2.2. Order

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(x => x.OrderId);

        builder.Property(x => x.Priority)
            .HasConversion<string>();

        builder.Property(x => x.Status)
            .HasConversion<string>();

        builder.HasOne<Shift>()
            .WithMany()
            .HasForeignKey(x => x.ShiftId);
    }
}

2.3. Delivery

public class DeliveryConfiguration : IEntityTypeConfiguration<Delivery>
{
    public void Configure(EntityTypeBuilder<Delivery> builder)
    {
        builder.ToTable("Deliveries");
        builder.HasKey(x => x.DeliveryId);

        builder.Property(x => x.Priority)
            .HasConversion<string>();

        builder.Property(x => x.Status)
            .HasConversion<string>();

        builder.HasOne<Order>()
            .WithMany()
            .HasForeignKey(x => x.OrderId);

        builder.HasOne<Truck>()
            .WithMany()
            .HasForeignKey(x => x.TruckId)
            .IsRequired(false);

        builder.HasOne<Slot>()
            .WithOne()
            .HasForeignKey<Delivery>(x => x.SlotId)
            .IsRequired(false);
    }
}

2.4. Truck

public class TruckConfiguration : IEntityTypeConfiguration<Truck>
{
    public void Configure(EntityTypeBuilder<Truck> builder)
    {
        builder.ToTable("Trucks");
        builder.HasKey(x => x.TruckId);

        builder.Property(x => x.Status)
            .HasConversion<string>();
    }
}

2.5. Slot

public class SlotConfiguration : IEntityTypeConfiguration<Slot>
{
    public void Configure(EntityTypeBuilder<Slot> builder)
    {
        builder.ToTable("Slots");
        builder.HasKey(x => x.SlotId);

        builder.Property(x => x.Status)
            .HasConversion<string>();

        builder.HasOne<Truck>()
            .WithMany()
            .HasForeignKey(x => x.TruckId);

        builder.HasOne<Delivery>()
            .WithOne()
            .HasForeignKey<Slot>(x => x.DeliveryId);
    }
}

2.6. DomainEventEntity (для Outbox)

public class DomainEventEntity
{
    public string EventId { get; set; } = default!;
    public string EventType { get; set; } = default!;
    public DateTime OccurredAt { get; set; }
    public string PayloadJson { get; set; } = default!;
    public bool Processed { get; set; }
}
public class DomainEventConfiguration : IEntityTypeConfiguration<DomainEventEntity>
{
    public void Configure(EntityTypeBuilder<DomainEventEntity> builder)
    {
        builder.ToTable("DomainEvents");
        builder.HasKey(x => x.EventId);
    }
}

✅ 3. Web API: контроллеры

3.1. ShiftController

[ApiController]
[Route("api/shifts")]
public class ShiftController : ControllerBase
{
    private readonly IShiftScheduleService _shiftService;

    public ShiftController(IShiftScheduleService shiftService)
    {
        _shiftService = shiftService;
    }

    [HttpGet("{shiftId}/schedule")]
    public async Task<ActionResult<ShiftScheduleDto>> GetSchedule(string shiftId)
    {
        var schedule = await _shiftService.GetScheduleAsync(shiftId);
        return Ok(schedule);
    }

    [HttpPost("{shiftId}/recalculate")]
    public async Task<IActionResult> Recalculate(string shiftId)
    {
        await _shiftService.RecalculateAsync(shiftId);
        return Ok();
    }
}

3.2. DeliveryController

[ApiController]
[Route("api/deliveries")]
public class DeliveryController : ControllerBase
{
    private readonly IDeliveryService _deliveryService;

    public DeliveryController(IDeliveryService deliveryService)
    {
        _deliveryService = deliveryService;
    }

    [HttpPost("{deliveryId}/assign")]
    public async Task<IActionResult> Assign(string deliveryId)
    {
        var result = await _deliveryService.AssignDeliveryAsync(deliveryId);
        return Ok(result);
    }
}

3.3. EventsController (приём событий от телематики)

[ApiController]
[Route("api/events")]
public class EventsController : ControllerBase
{
    private readonly IEventBus _eventBus;

    public EventsController(IEventBus eventBus)
    {
        _eventBus = eventBus;
    }

    [HttpPost("truck-broken")]
    public IActionResult TruckBroken(TruckBrokenDto dto)
    {
        var evt = new TruckBrokenEvent
        {
            ShiftId = dto.ShiftId,
            TruckId = dto.TruckId,
            BreakdownTime = dto.BreakdownTime,
            ExpectedBackOnline = dto.ExpectedBackOnline
        };

        _eventBus.Publish(evt);
        return Ok();
    }
}

✅ 4. DTO + маппинг

4.1. DTO для расписания

public class ShiftScheduleDto
{
    public string ShiftId { get; set; } = default!;
    public List<TruckScheduleDto> Trucks { get; set; } = new();
    public List<DeliveryDto> Deliveries { get; set; } = new();
}

public class TruckScheduleDto
{
    public string TruckId { get; set; } = default!;
    public List<SlotDto> Slots { get; set; } = new();
}

public class SlotDto
{
    public string SlotId { get; set; } = default!;
    public string DeliveryId { get; set; } = default!;
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public DateTime UnloadStartTime { get; set; }
}

4.2. AutoMapper Profile

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<ShiftSchedule, ShiftScheduleDto>()
            .ForMember(d => d.Trucks, opt => opt.MapFrom(s => s.TruckSchedules))
            .ForMember(d => d.Deliveries, opt => opt.MapFrom(s => s.Deliveries));

        CreateMap<TruckSchedule, TruckScheduleDto>();
        CreateMap<Slot, SlotDto>();
        CreateMap<Delivery, DeliveryDto>();
    }
}

✅ 5. Application Layer (сервисный слой)

5.1. ShiftScheduleService

public interface IShiftScheduleService
{
    Task<ShiftScheduleDto> GetScheduleAsync(string shiftId);
    Task RecalculateAsync(string shiftId);
}

public class ShiftScheduleService : IShiftScheduleService
{
    private readonly IShiftScheduleRepository _repo;
    private readonly IMapper _mapper;
    private readonly ScheduleRecalcService _recalc;

    public ShiftScheduleService(
        IShiftScheduleRepository repo,
        IMapper mapper,
        ScheduleRecalcService recalc)
    {
        _repo = repo;
        _mapper = mapper;
        _recalc = recalc;
    }

    public Task<ShiftScheduleDto> GetScheduleAsync(string shiftId)
    {
        var schedule = _repo.GetByShift(shiftId);
        return Task.FromResult(_mapper.Map<ShiftScheduleDto>(schedule));
    }

    public Task RecalculateAsync(string shiftId)
    {
        // вручную инициируем перерасчёт
        var evt = new TruckBrokenEvent
        {
            ShiftId = shiftId,
            TruckId = "T1",
            BreakdownTime = DateTime.UtcNow
        };

        _recalc.HandleTruckBroken(evt);
        return Task.CompletedTask;
    }
}

✅ 6. Как это выглядит в реальном ASP.NET Core приложении

Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<LogisticsDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<ITruckRepository, EfTruckRepository>();
builder.Services.AddScoped<IDeliveryRepository, EfDeliveryRepository>();
builder.Services.AddScoped<ISlotRepository, EfSlotRepository>();
builder.Services.AddScoped<IShiftScheduleRepository, EfShiftScheduleRepository>();

builder.Services.AddScoped<IEventBus, InMemoryEventBus>();
builder.Services.AddScoped<ITravelTimeProvider, SimpleTravelTimeProvider>();
builder.Services.AddScoped<SchedulingService>();
builder.Services.AddScoped<ScheduleRecalcService>();
builder.Services.AddScoped<IShiftScheduleService, ShiftScheduleService>();

builder.Services.AddAutoMapper(typeof(MappingProfile));
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

✅ 7. Что мы получили

✅ EF Core модель
✅ Fluent API конфигурации
✅ DbContext
✅ Web API контроллеры
✅ DTO + AutoMapper
✅ Application Layer
✅ Domain Layer
✅ Infrastructure Layer (in‑memory + EF-ready)
✅ EventBus + обработка форс‑мажора
✅ Полный пайплайн от HTTP → Domain → EF

Это уже реальный каркас логистической системы, который можно развивать.

Цельный каркас для реального ASP.NET Core/EF Core решения:

  • EF Core миграции (как их организовать),
  • EF‑репозитории,
  • фоновый EventProcessor (HostedService),
  • Swagger/OpenAPI.

1. EF Core миграции

1.1. Подготовка DbContext

У нас уже есть LogisticsDbContext. Главное — он должен быть в отдельном проекте или в том же, где ты делаешь миграции. Конфигурация модели уже была показана.

1.2. Подключение EF Core в Program.cs

builder.Services.AddDbContext<LogisticsDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

В appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=LogisticsDb;Trusted_Connection=True;TrustServerCertificate=True"
  }
}

1.3. Создание миграций

В проекте, где находится LogisticsDbContext, из консоли:

dotnet ef migrations add InitialCreate -p YourProjectWithDbContext.csproj -s YourWebApiProject.csproj
dotnet ef database update -p YourProjectWithDbContext.csproj -s YourWebApiProject.csproj
  • -p — проект, где DbContext,
  • -s — стартовый проект (Web API).

Миграция сгенерирует таблицы Shifts, Orders, Deliveries, Trucks, Slots, DomainEvents и т. д. на основе Fluent‑конфигураций.


2. EF‑репозитории

Заменим in‑memory реализации на EF‑варианты.

2.1. EfTruckRepository

public class EfTruckRepository : ITruckRepository
{
    private readonly LogisticsDbContext _db;

    public EfTruckRepository(LogisticsDbContext db)
    {
        _db = db;
    }

    public Truck? GetById(string truckId)
        => _db.Trucks.FirstOrDefault(t => t.TruckId == truckId);

    public IReadOnlyList<Truck> GetAllActive()
        => _db.Trucks.Where(t => t.IsActive).ToList();

    public void Save(Truck truck)
    {
        var existing = _db.Trucks.FirstOrDefault(t => t.TruckId == truck.TruckId);
        if (existing == null)
            _db.Trucks.Add(truck);
        else
            _db.Entry(existing).CurrentValues.SetValues(truck);

        _db.SaveChanges();
    }
}

2.2. EfDeliveryRepository

public class EfDeliveryRepository : IDeliveryRepository
{
    private readonly LogisticsDbContext _db;

    public EfDeliveryRepository(LogisticsDbContext db)
    {
        _db = db;
    }

    public Delivery? GetById(string deliveryId)
        => _db.Deliveries.FirstOrDefault(d => d.DeliveryId == deliveryId);

    public IReadOnlyList<Delivery> GetByShift(string shiftId)
        => _db.Deliveries.Where(d => d.ShiftId == shiftId).ToList();

    public void Save(Delivery delivery)
    {
        var existing = _db.Deliveries.FirstOrDefault(d => d.DeliveryId == delivery.DeliveryId);
        if (existing == null)
            _db.Deliveries.Add(delivery);
        else
            _db.Entry(existing).CurrentValues.SetValues(delivery);

        _db.SaveChanges();
    }
}

2.3. EfSlotRepository

public class EfSlotRepository : ISlotRepository
{
    private readonly LogisticsDbContext _db;

    public EfSlotRepository(LogisticsDbContext db)
    {
        _db = db;
    }

    public IReadOnlyList<Slot> GetByShift(string shiftId)
        => _db.Slots.Where(s => s.ShiftId == shiftId).ToList();

    public IReadOnlyList<Slot> GetByTruck(string shiftId, string truckId)
        => _db.Slots.Where(s => s.ShiftId == shiftId && s.TruckId == truckId).ToList();

    public void Save(Slot slot)
    {
        var existing = _db.Slots.FirstOrDefault(s => s.SlotId == slot.SlotId);
        if (existing == null)
            _db.Slots.Add(slot);
        else
            _db.Entry(existing).CurrentValues.SetValues(slot);

        _db.SaveChanges();
    }

    public void Delete(Slot slot)
    {
        var existing = _db.Slots.FirstOrDefault(s => s.SlotId == slot.SlotId);
        if (existing != null)
        {
            _db.Slots.Remove(existing);
            _db.SaveChanges();
        }
    }
}

2.4. EfShiftScheduleRepository

Здесь есть выбор: либо полностью собирать агрегат в памяти, либо делать более «проекционное» чтение. Для простоты — как раньше, только поверх EF:

public class EfShiftScheduleRepository : IShiftScheduleRepository
{
    private readonly LogisticsDbContext _db;

    public EfShiftScheduleRepository(LogisticsDbContext db)
    {
        _db = db;
    }

    public ShiftSchedule GetByShift(string shiftId)
    {
        var deliveries = _db.Deliveries.Where(d => d.ShiftId == shiftId).ToList();
        var slots = _db.Slots.Where(s => s.ShiftId == shiftId).ToList();
        var trucks = _db.Trucks.Where(t => t.IsActive).ToList();
        var shift = _db.Shifts.First(s => s.ShiftId == shiftId);

        var schedule = new ShiftSchedule(shiftId, shift.StartTime, shift.EndTime);
        schedule.Deliveries.AddRange(deliveries);

        foreach (var truck in trucks)
        {
            var ts = new TruckSchedule(shiftId, truck.TruckId);
            foreach (var slot in slots.Where(s => s.TruckId == truck.TruckId))
            {
                ts.AddSlot(slot);
            }
            schedule.TruckSchedules.Add(ts);
        }

        return schedule;
    }

    public void Save(ShiftSchedule schedule)
    {
        // deliveries
        foreach (var d in schedule.Deliveries)
        {
            var existing = _db.Deliveries.FirstOrDefault(x => x.DeliveryId == d.DeliveryId);
            if (existing == null)
                _db.Deliveries.Add(d);
            else
                _db.Entry(existing).CurrentValues.SetValues(d);
        }

        // slots
        foreach (var ts in schedule.TruckSchedules)
        {
            foreach (var s in ts.Slots)
            {
                var existing = _db.Slots.FirstOrDefault(x => x.SlotId == s.SlotId);
                if (existing == null)
                    _db.Slots.Add(s);
                else
                    _db.Entry(existing).CurrentValues.SetValues(s);
            }
        }

        _db.SaveChanges();
    }
}

3. Фоновый EventProcessor (HostedService)

Задача: забирать необработанные события из таблицы DomainEvents и вызывать наш ScheduleRecalcService.

3.1. Entity для событий (Outbox)

У нас уже есть DomainEventEntity. Добавим поле ShiftId, TruckId, DeliveryId:

public class DomainEventEntity
{
    public string EventId { get; set; } = default!;
    public string EventType { get; set; } = default!;
    public DateTime OccurredAt { get; set; }
    public string PayloadJson { get; set; } = default!;
    public bool Processed { get; set; }

    public string? ShiftId { get; set; }
    public string? TruckId { get; set; }
    public string? DeliveryId { get; set; }
}

3.2. HostedService

public class EventProcessorHostedService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<EventProcessorHostedService> _logger;

    public EventProcessorHostedService(
        IServiceScopeFactory scopeFactory,
        ILogger<EventProcessorHostedService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("EventProcessorHostedService started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<LogisticsDbContext>();
                var recalcService = scope.ServiceProvider.GetRequiredService<ScheduleRecalcService>();

                var events = await db.DomainEvents
                    .Where(e => !e.Processed)
                    .OrderBy(e => e.OccurredAt)
                    .Take(50)
                    .ToListAsync(stoppingToken);

                foreach (var e in events)
                {
                    await ProcessEventAsync(e, recalcService, stoppingToken);
                    e.Processed = true;
                }

                await db.SaveChangesAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error in EventProcessorHostedService");
            }

            // Пауза между циклами
            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }

    private Task ProcessEventAsync(
        DomainEventEntity e,
        ScheduleRecalcService recalcService,
        CancellationToken ct)
    {
        switch (e.EventType)
        {
            case "TruckBroken":
            {
                var evt = JsonSerializer.Deserialize<TruckBrokenEvent>(e.PayloadJson);
                if (evt != null)
                    recalcService.HandleTruckBroken(evt);
                break;
            }
            case "UnloadDelayOccurred":
            {
                var evt = JsonSerializer.Deserialize<UnloadDelayOccurredEvent>(e.PayloadJson);
                if (evt != null)
                    recalcService.HandleUnloadDelay(evt);
                break;
            }
            default:
                break;
        }

        return Task.CompletedTask;
    }
}

3.3. Регистрация HostedService

В Program.cs:

builder.Services.AddHostedService<EventProcessorHostedService>();

4. Swagger / OpenAPI

4.1. Подключение Swashbuckle

В .csproj:

<ItemGroup>
  <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>

4.2. Регистрация в Program.cs

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Logistics API v1");
    });
}

app.MapControllers();
app.Run();

Теперь при запуске в Dev среде будет доступен UI по /swagger.


5. Финальная сборка пайплайна

На входе:

  • HTTP запросы:
    • GET /api/shifts/{id}/schedule
    • POST /api/shifts/{id}/recalculate
    • POST /api/events/truck-broken (или напрямую в DomainEvents из интеграции).

Внутри:

  • Контроллеры → Application‑сервисы (ShiftScheduleService, ScheduleRecalcService) → доменные сервисы (SchedulingService) → EF‑репозитории → SQL.

Фоново:

  • EventProcessorHostedService периодически забирает события из DomainEvents,
  • десериализует полезную нагрузку,
  • вызывает ScheduleRecalcService,
  • тот обновляет Deliveries, Slots,
  • изменения отражаются в расписании, которое читает UI.

Дальше  можно сделать:

  • добавить конкретные EF‑миграции (сгенерированный код),
  • сделать пример TruckBrokenEvent → запись в DomainEvents через API,
  • оптимизация: батч‑сохранения, транзакции, idempotency‑ключи и пр.