Skip to main content

Вариант 2

Логистика бетона (жидкого цемента) — одна из самых сложных сфер, так как продукт «живет» ограниченное время (схватывается), и цена ошибки высока.

Ниже представлено подробное описание бизнес-процесса, формализация модели и алгоритмика решения.


1. Формализация ситуации: Игровое поле

Мы рассматриваем систему как Динамическое планирование ресурсов с жесткими временными окнами.

Главная цель: Выполнить все заказы клиентов в течение 12-часовой смены, минимизируя простой машин и опоздания, с возможностью мгновенной реакции на хаос (форс-мажор).

Ключевые концепции

  • Смена (Shift): Фиксированный отрезок времени T=[0, 720] (минут), например с 07:00 до 19:00.

  • Заказ (Order): Глобальная потребность клиента (например, 20 кубов к 10:00).

  • Поставка (Delivery/Task): Атомарная единица работы. Это одна "ходка" машины.

  • Слот (Slot): Зарезервированный прямоугольник на диаграмме Ганта конкретной машины.

  • Пул (Pool): Множество доступных ресурсов (машин).


2. Атомизация процесса: Жизненный цикл Поставки

Shutterstock

Чтобы алгоритм работал, процесс перевозки нужно разбить на этапы, из которых складывается длина слота.

Пусть t — это временные отрезки:

  1. Подача (tplant): Время пути от текущего положения машины до бетонного завода (точки погрузки).

  2. Погрузка (tload): Технологическое время заливки (например, 15 мин).

  3. Доставка (ttravel): Путь до клиента.

  4. Разгрузка (tunload): Самый критичный этап. Время, когда клиент принимает бетон. Это «якорь» слота.

  5. Форс-мажорный буфер (tbuffer): Добавочное время (например, 10% от пути) на пробки/заминки.

  6. Возврат/Очистка (treturn): Путь обратно или на мойку.

Формула слота:

image.png

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


3. Акторы, Атрибуты и События

Здесь мы используем событийную модель (Event-Driven). Все взаимодействия идут через интерфейсы.

Актор 1: Грузовик (Truck)

Физический исполнитель.

  • Атрибуты:

    • ID: Уникальный номер.

    • Capacity: Объем бочки (например, 5, 7, 10 кубов).

    • State: Состояние (Idle, Busy, Maintenance, OutOfService).

    • Location: Текущие координаты.

    • Schedule: Список назначенных Слотов (List<Slot>).

  • Методы:

    • AssignSlot(Slot s): Принять задачу.

    • ReportStatus(Status s): Сообщить о поломке/завершении.

Актор 2: Заказ (Order)

Родительская сущность.

  • Атрибуты:

    • TotalVolume: Общий объем (например, 15 кубов).

    • Location: Точка разгрузки.

    • Deadline: Желаемое время (или интервал) начала разгрузки первой машины.

    • Priority: Вес клиента (VIP, Стандарт).

  • Алгоритм разбиения:

    • Делит TotalVolume на части по 5-10 кубов (в зависимости от доступных стандартных объемов машин), создавая список сущностей Поставка (Delivery).

Актор 3: Диспетчер (Scheduler / Algorithm)

Мозг системы.

  • Задачи:

    • Мониторинг Пула свободных машин.

    • Расчет скоринга (рейтинга) машин для конкретной поставки.

    • Реакция на события (пересчет расписания).

Список Событий (Events)

  1. OnOrderReceived: Поступил новый заказ -> Запуск разбиения на поставки.

  2. OnDeliveryScheduled: Поставка превратилась в Слот на машине.

  3. OnTruckStatusChanged:

    • Broken: Машина сломалась.

    • Delayed: Машина опаздывает на $X$ минут.

  4. OnShiftEnded: Закрытие смены.


4. Алгоритмика: Распределение и Каскадный перерасчет

Это "сердце" вашей системы. Она должна быть простой и визуализируемой на Ганте.

Алгоритм А: Первичное распределение (Greedy Best-Fit)

Когда появляется потребность в перевозке (Поставка), алгоритм ищет исполнителя:

  1. Фильтрация: Из всего парка выбрать машины, у которых State != OutOfService и Capacity >= DeliveryVolume.

  2. Поиск окон: Для каждой машины проверить ее расписание на Ганте. Есть ли свободное время между существующими слотами (или после последнего), достаточное для Tslot?

    image.png

  3. Скоринг (Взвешивание): Каждому кандидату присваивается балл. Чем меньше балл, тем лучше.

    image.png

  4. Назначение: Машина с лучшим скором получает Слот. Слот «бронирует» время на диаграмме Ганта.

Алгоритм Б: Каскадный перерасчет (Ripple Effect)

Самое интересное происходит при форс-мажоре.

Сценарий 1: Задержка (Delay)

Машина M_1 застряла на разгрузке на 30 минут.

  1. Сдвиг: Текущий слот S_1 расширяется вправо на 30 мин.

  2. Коллизия: Проверяем следующий слот S_2 этой же машины. Если S_1.End > S_2.Start, то S_2 тоже сдвигается вправо.

  3. Валидация: Если сдвиг S_2 приводит к тому, что машина не успевает к клиенту (слишком поздно) или выходит за границы смены:

    • Слот S_2 аннулируется у машины M_1.

    • Поставка из S_2 возвращается в статус "New" с пометкой "High Priority".

    • Запускается Алгоритм А для поиска новой машины для этой поставки.

Сценарий 2: Поломка (Breakdown)

Машина M_1 сломалась в 12:00.

  1. Блокировка: Статус машины -> OutOfService.

  2. Очистка: Все будущие слоты (S_2, S_3...) начиная с 12:00 удаляются у этой машины.

  3. Ре-пулинг: Все удаленные поставки выбрасываются в общий пул задач с наивысшим приоритетом.

  4. Перераспределение: Система экстренно ищет свободные машины (или окна у занятых) для "сиротских" поставок.


5. Визуализация: Диаграмма Ганта

Представьте таблицу, где:

  • Ось Y: Список машин (Грузовик 1, Грузовик 2...).

  • Ось X: Время (07:00 ... 19:00).

  • Блоки: Цветные прямоугольники (Слоты).

    • Зеленый: Едет/Грузится.

    • Красный: Стоит на разгрузке (основная цель).

    • Серый: Порожний пробег/Возврат.

Правило Ганта: Блоки на одной строке (одной машине) не могут пересекаться. Между ними должен быть минимальный технический зазор.

6. Девиации и Подводные камни (Мировая практика)

При разработке учтите эти реальные проблемы логистики бетона:

  1. Проблема "Непрерывной заливки":

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

    • Решение: Группа поставок одного заказа должна линковаться. Если одна машина опаздывает, следующая должна (по возможности) ускориться или алгоритм должен переставить другую машину вперед. Это "Связанные слоты".

  2. Остатки бетона (Return Concrete):

    • Суть: Клиент заказал 5 кубов, вылил 4. В машине остался 1 куб. Бетон нельзя везти другому клиенту (марка, время жизни).

    • Решение: Машина помечается флагом "Dirty", она обязана ехать на утилизацию/промывку перед следующим заказом. Это увеличивает время $t_{return}$treturn.

  3. Ограничения на въезд:

    • В центре города могут ездить только машины до 5 тонн. Алгоритм фильтрации должен учитывать атрибут ZoneRestriction у машины и заказа.

Проект архитектуры на C#, который переводит описанную выше логику в строгие типы и контракты.

Логические блоки: Примитивы времени (база для Ганта), Сущности (Данные) и Акторы (Поведение).

1. Базовые примитивы (Основа Диаграммы Ганта)

Самое важное — правильно работать со временем и пересечениями. Создадим структуру TimeWindow, чтобы не писать каждый раз проверки Start < End.

C#

using System;
using System.Collections.Generic;
using System.Linq;

namespace ConcreteLogistics.Core
{
    // Структура для любого временного отрезка на диаграмме Ганта
    public struct TimeWindow
    {
        public DateTime Start { get; }
        public DateTime End { get; }
        public TimeSpan Duration => End - Start;

        public TimeWindow(DateTime start, TimeSpan duration)
        {
            Start = start;
            End = start.Add(duration);
        }

        public TimeWindow(DateTime start, DateTime end)
        {
            if (end < start) throw new ArgumentException("End cannot be before Start");
            Start = start;
            End = end;
        }

        // Ключевой метод для алгоритма распределения
        // Проверяет, накладывается ли этот слот на другой
        public bool IntersectsWith(TimeWindow other)
        {
            return Start < other.End && other.Start < End;
        }

        // Проверка, помещается ли слот внутри смены
        public bool IsWithin(TimeWindow outerWindow)
        {
            return Start >= outerWindow.Start && End <= outerWindow.End;
        }
    }
}

2. Данные и Состояния (Enums & Models)

Определим словари состояний и метрики, из которых состоит слот.

C#

namespace ConcreteLogistics.Core
{
    public enum TruckStatus
    {
        Idle,           // Свободен, стоит в парке
        Active,         // Выполняет слот (едет, грузится, разгружается)
        Maintenance,    // Плановое ТО
        OutOfService    // Поломка/ДТП (Критично для перерасчета)
    }

    public enum DeliveryPriority
    {
        Standard = 0,
        High = 1,       // Важный клиент
        Critical = 2    // "Горящий" заказ после сбоя (Cascade Recovery)
    }

    // Детализация времени внутри слота (из чего складывается длина кирпичика на Ганте)
    public class SlotMetrics
    {
        public TimeSpan TravelToPlant { get; set; } // Подача
        public TimeSpan Loading { get; set; }       // Погрузка
        public TimeSpan TravelToSite { get; set; }  // Путь к клиенту
        public TimeSpan Unloading { get; set; }     // Разгрузка (Ядро слота)
        public TimeSpan ReturnBase { get; set; }    // Возврат
        public TimeSpan Buffer { get; set; }        // Форс-мажорный запас

        public TimeSpan TotalDuration => 
            TravelToPlant + Loading + TravelToSite + Unloading + ReturnBase + Buffer;
    }
}

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

Поставка (IDelivery) и Слот (ISlot)

Поставка — это потребность. Слот — это поставка, "прибитая" к конкретному времени и машине.

C#

namespace ConcreteLogistics.Interfaces
{
    // Атомарная задача на перевозку (часть большого Заказа)
    public interface IDelivery
    {
        Guid Id { get; }
        Guid OrderId { get; }           // Ссылка на родительский заказ
        double Volume { get; }          // Например, 5.0 кубов
        GeoLocation TargetLocation { get; }
        TimeWindow DesiredDeliveryWindow { get; } // Когда клиент хочет получить бетон
        DeliveryPriority Priority { get; set; }
    }

    // Запланированная работа на диаграмме Ганта
    public interface ISlot
    {
        Guid Id { get; }
        ITruck AssignedTruck { get; }
        IDelivery Delivery { get; }
        
        // Временные рамки
        TimeWindow Window { get; } // Start = начало подачи, End = конец возврата
        
        // Точное время, когда бетон начнет литься (для клиента)
        DateTime PouringStartTime { get; } 
        
        // Метрики для анализа эффективности
        SlotMetrics Metrics { get; }
        
        // Флаг для перекрытий
        bool Overlaps(ISlot other);
    }
}

Грузовик (ITruck)

Машина знает о своих характеристиках и расписании.

C#

namespace ConcreteLogistics.Interfaces
{
    public interface ITruck
    {
        string RegistrationNumber { get; }
        double MaxCapacity { get; } // Вместимость бочки
        
        TruckStatus CurrentStatus { get; }
        GeoLocation CurrentLocation { get; }
        
        // Расписание машины (список слотов)
        // Должно быть отсортировано по времени
        IReadOnlyList<ISlot> Schedule { get; }

        // Проверка: Может ли машина взять эту поставку в это время?
        // Внутри проверяется Capacity и пересечения TimeWindow с существующими слотами
        bool CanAccept(IDelivery delivery, TimeWindow proposedWindow);

        // Назначение слота
        void AssignSlot(ISlot slot);

        // Событие: Машина сломалась или освободилась
        void UpdateStatus(TruckStatus newStatus);
        
        // Метод для очистки расписания при поломке (возвращает отмененные слоты)
        IEnumerable<ISlot> ClearFutureSlots(DateTime fromTime);
    }
}

4. Мозг системы: Планировщик и События

Здесь реализована событийная модель, о которой мы говорили.

C#

namespace ConcreteLogistics.Interfaces
{
    // Аргументы событий для шины данных
    public class RescheduleRequiredEventArgs : EventArgs
    {
        public string Reason { get; set; } // Например, "Truck 5 Breakdown"
        public List<IDelivery> OrphanedDeliveries { get; set; } // Поставки, потерявшие машину
    }

    public interface IFleetScheduler
    {
        // 1. Входные данные: Пул машин и Смена
        void InitializeShift(IEnumerable<ITruck> fleet, TimeWindow shiftDuration);

        // 2. Основной метод: Обработка нового заказа
        // Разбивает заказ на Deliveries и пытается найти слоты
        bool ProcessOrder(IOrder order);

        // 3. Метод ручного или автоматического поиска машины для конкретной поставки
        // Возвращает true, если слот успешно создан
        bool TryFindSlot(IDelivery delivery, out ISlot scheduledSlot);

        // --- Событийная модель ---

        // Подписка на изменение статуса машин (для каскадного пересчета)
        void OnTruckStatusChanged(ITruck truck, TruckStatus oldStatus, TruckStatus newStatus);

        // Событие, которое выбрасывает Планировщик, если случился форс-мажор,
        // и требуются действия оператора или полный пересчет
        event EventHandler<RescheduleRequiredEventArgs> RescheduleRequired;
    }
    
    // Родительский заказ
    public interface IOrder 
    {
        Guid Id { get; }
        double TotalVolume { get; }
        // Метод разбиения большого объема на части (например, 15 -> 5 + 5 + 5)
        IEnumerable<IDelivery> SplitIntoDeliveries(double standardTruckVolume);
    }
}

5. Пример логики: Проверка коллизий (Implementation Sketch)

Вот как выглядит реализация метода CanAccept внутри класса Truck, чтобы вы увидели, как работает математика "без перекрытий".

C#

public class ConcreteTruck : ITruck
{
    // ... инициализация свойств ...
    private List<ISlot> _schedule = new List<ISlot>();

    public bool CanAccept(IDelivery delivery, TimeWindow proposedWindow)
    {
        // 1. Базовая проверка физики
        if (delivery.Volume > this.MaxCapacity) return false;
        if (this.CurrentStatus == TruckStatus.OutOfService) return false;

        // 2. Проверка на пересечение с уже существующими слотами
        // Используем метод IntersectsWith из нашей структуры TimeWindow
        bool hasOverlap = _schedule.Any(existingSlot => 
            existingSlot.Window.IntersectsWith(proposedWindow));

        if (hasOverlap) return false;

        // 3. (Опционально) Проверка географической связности
        // Успеет ли машина доехать от конца предыдущего слота до начала нового?
        // Это обычно закладывается в TravelToPlant, но можно проверить явно.
        
        return true;
    }
}

Рекомендации по реализации:

  1. GeoLocation: Для простоты пока можно использовать заглушку (X, Y) и считать расстояние по прямой (Manhattan or Euclidean), но в реальности лучше сразу заложить интерфейс IRoutingService, который будет возвращать реальное время в пути с учетом пробок.

  2. Immutability: Старайтесь делать TimeWindow и SlotMetrics неизменяемыми (immutable). Это спасет от ошибок при многопоточном пересчете расписания.

  3. Потокобезопасность: Если планировщик работает в веб-сервисе, доступ к списку _schedule внутри грузовика должен быть защищен (lock), так как диспетчер может читать его, пока водитель обновляет статус.

 

Ниже представлена реализация «мозга» системы. В нем объединена математика скоринга, поиск временных окон (Time Windows) и логика каскадного перерасчета в чистый C# код.

Для работы этого кода подразумевается использование интерфейсов (ITruck, ISlot и т.д.).

1. Вспомогательные сервисы (Mock-up)

В реальности здесь будет вызов Google Maps API или OSRM. Для алгоритма нам нужна имитация расчета времени пути.

C#

public static class RouteCalculator
{
    // Средняя скорость грузовика (км/ч) -> км/мин
    private const double AvgSpeedKmPerMin = 40.0 / 60.0; 

    public static TimeSpan CalculateTravelTime(GeoLocation from, GeoLocation to)
    {
        // Упрощенная евклидова метрика для примера
        double distance = Math.Sqrt(Math.Pow(from.X - to.X, 2) + Math.Pow(from.Y - to.Y, 2));
        double minutes = distance / AvgSpeedKmPerMin;
        
        // Добавляем 10% на светофоры
        return TimeSpan.FromMinutes(minutes * 1.1);
    }
}

2. Алгоритм I: Разбиение заказа (Order Splitting)

Превращает «Хочу 18 кубов бетона» в набор конкретных задач для автопарка.

C#

public class OrderSplitter
{
    public IEnumerable<IDelivery> SplitOrder(IOrder order, double standardTruckCapacity = 7.0)
    {
        var deliveries = new List<IDelivery>();
        double remainingVolume = order.TotalVolume;
        int partNumber = 1;

        // Пока есть что возить
        while (remainingVolume > 0)
        {
            // Берем либо полную машину, либо остаток
            double volumeToTake = Math.Min(remainingVolume, standardTruckCapacity);
            
            // Создаем сущность Поставки (реализация IDelivery)
            var delivery = new DeliveryTask
            {
                Id = Guid.NewGuid(),
                OrderId = order.Id,
                Volume = volumeToTake,
                TargetLocation = order.Location,
                Priority = order.Priority,
                // Для бетона важно: каждая следующая машина должна прийти через 15-20 мин
                // чтобы не было "холодного шва". Сдвигаем желаемое время.
                DesiredDeliveryWindow = new TimeWindow(
                    order.Deadline.AddMinutes((partNumber - 1) * 20), 
                    TimeSpan.FromMinutes(30) // Окно допуска
                )
            };

            deliveries.Add(delivery);
            remainingVolume -= volumeToTake;
            partNumber++;
        }
        return deliveries;
    }
}

3. Алгоритм II: Поиск окна и Скоринг (Allocation Logic)

Это самая сложная часть. Нужно найти не просто свободную машину, а «лучшую» машину.

C#

public class AllocationAlgorithm
{
    // Весовые коэффициенты для скоринга (можно вынести в конфиг)
    private const double WeightDistance = 1.0;  // Важность близости
    private const double WeightWaste = 50.0;    // Штраф за полупустую бочку (очень важно)
    private const double WeightDelay = 2.0;     // Штраф за отклонение от желаемого времени

    /// <summary>
    /// Ищет лучшую машину и точный слот для конкретной поставки
    /// </summary>
    public ISlot FindBestSlot(IEnumerable<ITruck> fleet, IDelivery delivery, TimeWindow shiftLimit)
    {
        ISlot bestSlot = null;
        double bestScore = double.MaxValue;

        // 1. Фильтрация пула (исключаем сломанные и маленькие машины)
        var candidates = fleet
            .Where(t => t.CurrentStatus != TruckStatus.OutOfService)
            .Where(t => t.MaxCapacity >= delivery.Volume);

        foreach (var truck in candidates)
        {
            // 2. Пытаемся найти свободное время в расписании грузовика
            var candidateSlot = FindFirstAvailableGap(truck, delivery, shiftLimit);

            if (candidateSlot != null)
            {
                // 3. Расчет рейтинга (Score)
                double score = CalculateScore(truck, delivery, candidateSlot);

                // Если этот вариант лучше найденных ранее — запоминаем
                if (score < bestScore)
                {
                    bestScore = score;
                    bestSlot = candidateSlot;
                }
            }
        }

        return bestSlot; // Вернет null, если машин нет
    }

    private ISlot FindFirstAvailableGap(ITruck truck, IDelivery delivery, TimeWindow shiftLimit)
    {
        // Сортируем существующие слоты по времени
        var orderedSlots = truck.Schedule.OrderBy(s => s.Window.Start).ToList();
        
        // Точка старта поиска (либо начало смены, либо "сейчас", если смена уже идет)
        DateTime searchStart = DateTime.Now > shiftLimit.Start ? DateTime.Now : shiftLimit.Start;
        
        // Начальная локация (база или текущее местоположение)
        GeoLocation lastLocation = truck.CurrentLocation;

        // Проход по "дыркам" между слотами
        // Добавляем фиктивный слот в конец, чтобы проверить время после последней задачи
        for (int i = 0; i <= orderedSlots.Count; i++)
        {
            DateTime gapStart = (i == 0) ? searchStart : orderedSlots[i - 1].Window.End;
            DateTime gapEnd = (i == orderedSlots.Count) ? shiftLimit.End : orderedSlots[i].Window.Start;

            // Если дырка схлопнулась (нет времени), пропускаем
            if (gapEnd <= gapStart) continue;

            // --- Симуляция физики слота ---
            // 1. Время доехать до завода (от последней точки)
            // (В реальности точка погрузки фиксирована - завод)
            var plantLocation = new GeoLocation(0, 0); // Допустим, завод в (0,0)
            TimeSpan timeToPlant = RouteCalculator.CalculateTravelTime(lastLocation, plantLocation);
            
            // 2. Время начала погрузки (самое раннее)
            DateTime readyToLoad = gapStart.Add(timeToPlant);

            // 3. Полный расчет метрик слота
            var metrics = new SlotMetrics
            {
                TravelToPlant = timeToPlant,
                Loading = TimeSpan.FromMinutes(15), // Константа погрузки
                TravelToSite = RouteCalculator.CalculateTravelTime(plantLocation, delivery.TargetLocation),
                Unloading = TimeSpan.FromMinutes(30), // Разгрузка
                ReturnBase = RouteCalculator.CalculateTravelTime(delivery.TargetLocation, plantLocation), // Возврат на базу/завод
                Buffer = TimeSpan.FromMinutes(15) // Буфер
            };

            // 4. Проверка: влезает ли вся эта активность в дырку?
            DateTime slotEnd = readyToLoad.Add(metrics.TotalDuration);

            if (slotEnd <= gapEnd)
            {
                // УРА! Слот найден. Создаем объект (но пока не назначаем)
                return new ConcreteSlot(truck, delivery, readyToLoad, metrics);
            }

            // Обновляем локацию для следующей итерации (конец текущего слота)
            if (i < orderedSlots.Count) 
                lastLocation = orderedSlots[i].Delivery.TargetLocation; // Или база, зависит от логики
        }

        return null; // Нет места
    }

    private double CalculateScore(ITruck truck, IDelivery delivery, ISlot slot)
    {
        // 1. Порожний пробег (плохо)
        double emptyRun = slot.Metrics.TravelToPlant.TotalMinutes + slot.Metrics.ReturnBase.TotalMinutes;
        
        // 2. Потеря вместимости (везти 5 кубов в 10-кубовой машине — плохо)
        double wastedCapacity = truck.MaxCapacity - delivery.Volume;

        // 3. Отклонение от желаемого времени клиента
        double timeDeviation = Math.Abs((slot.PouringStartTime - delivery.DesiredDeliveryWindow.Start).TotalMinutes);

        // Формула целевой функции (минимизация)
        return (emptyRun * WeightDistance) + 
               (wastedCapacity * WeightWaste) + 
               (timeDeviation * WeightDelay);
    }
}

4. Алгоритм III: Каскадный перерасчет (The Scheduler)

Этот класс связывает всё воедино и реагирует на форс-мажор.

C#

public class FleetScheduler : IFleetScheduler
{
    private readonly List<ITruck> _fleet;
    private readonly AllocationAlgorithm _algo;
    private readonly TimeWindow _shift;

    // Очередь "сиротских" заказов (тех, чьи машины сломались)
    private Queue<IDelivery> _emergencyQueue = new Queue<IDelivery>();

    public FleetScheduler(List<ITruck> fleet, TimeWindow shift)
    {
        _fleet = fleet;
        _shift = shift;
        _algo = new AllocationAlgorithm();
    }

    // --- Реакция на поломку (Cascade Logic) ---
    public void ReportTruckBreakdown(string truckId)
    {
        var truck = _fleet.FirstOrDefault(t => t.RegistrationNumber == truckId);
        if (truck == null) return;

        Console.WriteLine($"[ALERT] Truck {truckId} broken down. Initiating cascade recalculation.");

        // 1. Меняем статус
        truck.UpdateStatus(TruckStatus.OutOfService);

        // 2. Очищаем всё будущее расписание этой машины с текущего момента
        // Метод ClearFutureSlots должен вернуть список отмененных слотов
        var cancelledSlots = truck.ClearFutureSlots(DateTime.Now);

        // 3. Извлекаем поставки и повышаем им приоритет
        foreach (var slot in cancelledSlots)
        {
            var delivery = slot.Delivery;
            delivery.Priority = DeliveryPriority.Critical; // Помечаем как "Горящий"
            _emergencyQueue.Enqueue(delivery);
        }

        // 4. Запускаем экстренное перераспределение
        ProcessEmergencyQueue();
    }

    private void ProcessEmergencyQueue()
    {
        while (_emergencyQueue.Count > 0)
        {
            var delivery = _emergencyQueue.Dequeue();
            
            // Пытаемся найти новую машину
            var newSlot = _algo.FindBestSlot(_fleet, delivery, _shift);

            if (newSlot != null)
            {
                // Нашли! Назначаем
                newSlot.AssignedTruck.AssignSlot(newSlot);
                Console.WriteLine($"[RECOVERY] Delivery {delivery.Id} reassigned to Truck {newSlot.AssignedTruck.RegistrationNumber}");
            }
            else
            {
                // КРИТИЧЕСКАЯ СИТУАЦИЯ: Нет свободных машин
                // Здесь можно реализовать логику "вытеснения" (Preemption)
                // Например, отменить менее важный заказ у другой машины, чтобы вставить этот.
                Console.WriteLine($"[CRITICAL] Could not reassign delivery {delivery.Id}. Human intervention required.");
                // Можно бросить событие для UI диспетчера
            }
        }
    }
}

Разбор ключевых моментов

  1. Логика FindFirstAvailableGap: Это прямая реализация вашего требования "без перекрытий". Мы идем хронологически: Конец предыдущего -> Начало следующего. Если Начало следующего - Конец предыдущего > Длительность требуемого слота, значит, мы можем туда встать.

  2. Форс-мажор (ReportTruckBreakdown): Обратите внимание, что мы не просто удаляем слоты, а сохраняем IDelivery (суть заказа) и кладем их в _emergencyQueue. Это гарантирует, что работа не потеряется.

  3. Приоритет: При аварии мы ставим DeliveryPriority.Critical. В алгоритме скоринга можно добавить проверку: если приоритет Критический, можно игнорировать "waste capacity" (везти 1 куб в 10-кубовой машине), лишь бы спасти заказ. 

Модуль Времени (Time Ticker)

Этот модуль реализует принцип "Шаг симуляции", продвигая время вперед и проверяя, какие запланированные события должны были произойти в этот интервал. Для того, чтобы система могла не только планировать, но и динамически реагировать.

1. Структура Событий Симулятора

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

C#

using System;
using System.Collections.Generic;

namespace ConcreteLogistics.Simulation
{
    // Общий базовый класс для событий симуляции
    public abstract class SimulationEventArgs : EventArgs
    {
        public DateTime EventTime { get; }
        public ISlot Slot { get; }
        public string Message { get; }

        protected SimulationEventArgs(ISlot slot, DateTime time, string message)
        {
            Slot = slot;
            EventTime = time;
            Message = message;
        }
    }

    // Событие 1: Машина прибыла на завод и готова к погрузке
    public class TruckReadyToLoadEvent : SimulationEventArgs
    {
        public TruckReadyToLoadEvent(ISlot slot, DateTime time) 
            : base(slot, time, $"Грузовик {slot.AssignedTruck.RegistrationNumber} готов к погрузке.") { }
    }

    // Событие 2: Началась разгрузка на объекте клиента (Pouring Start)
    public class DeliveryStartedEvent : SimulationEventArgs
    {
        public DeliveryStartedEvent(ISlot slot, DateTime time) 
            : base(slot, time, $"Началась разгрузка по заказу {slot.Delivery.OrderId}.") { }
    }

    // Событие 3: Поставка завершена (конец разгрузки + возврат)
    public class DeliveryCompletedEvent : SimulationEventArgs
    {
        public DeliveryCompletedEvent(ISlot slot, DateTime time) 
            : base(slot, time, $"Поставка {slot.Delivery.Id} полностью завершена.") { }
    }
}

2. Модуль Времени (Simulation Engine)

Класс SimulationEngine будет отвечать за продвижение времени и проверку расписания.

C#

namespace ConcreteLogistics.Simulation
{
    public interface ISimulationEngine
    {
        DateTime CurrentTime { get; }
        
        // События, которые нужно отслеживать
        event EventHandler<TruckReadyToLoadEvent> OnReadyToLoad;
        event EventHandler<DeliveryCompletedEvent> OnDeliveryCompleted;

        // Главный метод: продвижение времени
        void AdvanceTime(TimeSpan step);
    }

    public class SimulationEngine : ISimulationEngine
    {
        private DateTime _currentTime;
        private readonly IEnumerable<ITruck> _fleet;
        private readonly TimeWindow _shiftBoundary;

        public DateTime CurrentTime => _currentTime;

        public event EventHandler<TruckReadyToLoadEvent> OnReadyToLoad;
        public event EventHandler<DeliveryCompletedEvent> OnDeliveryCompleted;

        public SimulationEngine(IEnumerable<ITruck> fleet, TimeWindow shiftBoundary)
        {
            _fleet = fleet;
            _shiftBoundary = shiftBoundary;
            _currentTime = shiftBoundary.Start;
        }

        public void AdvanceTime(TimeSpan step)
        {
            DateTime nextTime = _currentTime.Add(step);
            
            // Если выходим за пределы смены, останавливаемся
            if (nextTime > _shiftBoundary.End)
            {
                nextTime = _shiftBoundary.End;
            }

            // Проверяем расписание всех машин
            foreach (var truck in _fleet)
            {
                foreach (var slot in truck.Schedule)
                {
                    // Предполагаем, что ISlot имеет доступ к ключевым моментам:
                    // Slot.ReadyToLoadTime = Время начала погрузки
                    // Slot.DeliveryEndTime = Время окончания разгрузки + возврат
                    
                    // 1. Проверяем событие "Готов к погрузке"
                    if (_currentTime < slot.ReadyToLoadTime && slot.ReadyToLoadTime <= nextTime)
                    {
                        OnReadyToLoad?.Invoke(this, new TruckReadyToLoadEvent(slot, slot.ReadyToLoadTime));
                    }

                    // 2. Проверяем событие "Поставка завершена"
                    if (_currentTime < slot.Window.End && slot.Window.End <= nextTime)
                    {
                        // В конце слота (Window.End) машина возвращается в пул
                        OnDeliveryCompleted?.Invoke(this, new DeliveryCompletedEvent(slot, slot.Window.End));
                    }
                }
            }

            // Двигаем время вперед
            _currentTime = nextTime;
        }
    }
}

3. Интеграция с Планировщиком (IFleetScheduler)

Для того чтобы события влияли на систему, Планировщик (Scheduler) должен на них подписаться.

3.1. Реакция на завершение поставки

При завершении поставки машина освобождается, и планировщик должен вернуть ее в пул свободных ресурсов (TruckStatus.Idle).

C#

// Часть класса FleetScheduler (пример подписки)

public class FleetScheduler : IFleetScheduler
{
    // ... поля и методы ...
    
    public void ConnectToEngine(ISimulationEngine engine)
    {
        // Подписка на ключевые события
        engine.OnDeliveryCompleted += HandleDeliveryCompleted;
    }

    private void HandleDeliveryCompleted(object sender, DeliveryCompletedEvent e)
    {
        var truck = e.Slot.AssignedTruck;
        
        // 1. Обновляем статус машины
        truck.UpdateStatus(TruckStatus.Idle);
        Console.WriteLine($"[STATUS] Грузовик {truck.RegistrationNumber} вернулся в пул.");
        
        // 2. Здесь можно запустить проверку очереди отложенных (High Priority) заказов,
        // используя свободную машину.
        // ProcessEmergencyQueue();
    }

    // ... (и подписка на другие события, например, для логирования)
}

3.2. Добавление "случайных" форс-мажоров

Для полноценного симулятора в SimulationEngine можно добавить небольшой шанс (например, 1% на каждом шаге) на возникновение форс-мажора, который будет запускать каскадный перерасчет.

C#

public void AdvanceTime(TimeSpan step)
{
    // ... код проверки расписания ...
    
    // --- Имитация Форс-мажора (1% шанс на шаг) ---
    if (new Random().Next(1, 100) == 1)
    {
        var activeTrucks = _fleet.Where(t => t.CurrentStatus == TruckStatus.Active).ToList();
        if (activeTrucks.Any())
        {
            var brokenTruck = activeTrucks[new Random().Next(activeTrucks.Count)];
            
            // Вместо прямого вызова, нужно сгенерировать событие,
            // которое подхватит планировщик и вызовет ReportTruckBreakdown
            // (e.g., OnTruckDisrupted?.Invoke(brokenTruck.RegistrationNumber));
            Console.WriteLine($"[SIM] Имитация поломки у {brokenTruck.RegistrationNumber}!");
        }
    }
    
    _currentTime = nextTime;
}

Следующий шаг:

Логика распределения, скоринга и симулятор времени. Финальный шаг — объединить все это в рабочий пример, где мы:

  1. Создаем 5 машин.

  2. Создаем 3 заказа.

  3. Запускаем FleetScheduler.ProcessOrder().

  4. Запускаем цикл SimulationEngine.AdvanceTime(), наблюдая за реакцией системы.

Единый рабочий сценарий симуляции на C#

Для простоты и наглядности созданы простые заглушки (Mock) для интерфейсов ITruck, ISlot и IDelivery, чтобы продемонстрировать, как основные алгоритмы (AllocationAlgorithm, FleetScheduler, SimulationEngine) взаимодействуют в течение смены.


1. Подготовка: Имитация Сущностей (Mocks)

Создадим минимальные реализации интерфейсов для демонстрации.

C#

// --- Имитация Геолокации ---
public class GeoLocation { public double X { get; set; } public double Y { get; set; } }

// --- Имитация Смены ---
public class ShiftInfo 
{
    public static readonly TimeWindow ShiftWindow = 
        new TimeWindow(DateTime.Today.AddHours(7), TimeSpan.FromHours(12));
}

// --- Имитация ITruck, IDelivery, ISlot (Упрощено для примера) ---
public class MockTruck : ITruck
{
    // ... [реализация ITruck (Status, Schedule, AssignSlot и т.д.)] ...
    // Включая метод ClearFutureSlots, который возвращает отмененные ISlot.
    // ...
}

public class MockDelivery : IDelivery
{
    // ... [реализация IDelivery] ...
}

public class MockSlot : ISlot
{
    // ... [реализация ISlot, включая PouringStartTime, ReadyToLoadTime, Window] ...
    // [Эти значения рассчитываются AllocationAlgorithm]
    // ...
}

2. Сценарий Симуляции (Simulation Scenario)

Мы прогоним симуляцию в три фазы: Планирование, Исполнение и Реакция на Форс-мажор.

C#

using System;
using System.Linq;
using System.Collections.Generic;
using ConcreteLogistics.Interfaces;
using ConcreteLogistics.Simulation;
using ConcreteLogistics.Core; // Для TimeWindow, TruckStatus и т.д.

public class SimulationScenario
{
    public static void RunSimulation()
    {
        Console.WriteLine("=== СТАРТ СИМУЛЯЦИИ ЛОГИСТИКИ БЕТОНА ===");
        
        // 1. Инициализация Пула Машин
        var fleet = new List<ITruck>
        {
            new MockTruck { RegistrationNumber = "Т01", MaxCapacity = 7.0, CurrentLocation = new GeoLocation { X = 1, Y = 1 } },
            new MockTruck { RegistrationNumber = "Т02", MaxCapacity = 10.0, CurrentLocation = new GeoLocation { X = 2, Y = 2 } },
            new MockTruck { RegistrationNumber = "Т03", MaxCapacity = 7.0, CurrentLocation = new GeoLocation { X = 50, Y = 50 } } // Далеко
        };

        // 2. Инициализация Диспетчера и Симулятора
        var scheduler = new FleetScheduler(fleet, ShiftInfo.ShiftWindow);
        var engine = new SimulationEngine(fleet, ShiftInfo.ShiftWindow);
        
        scheduler.ConnectToEngine(engine); // Подписка на события завершения поставок

        // 3. Планирование: Заказы
        var orderA = new MockOrder 
        { 
            TotalVolume = 14.0, 
            Deadline = DateTime.Today.AddHours(10), // К 10 утра
            Location = new GeoLocation { X = 15, Y = 15 },
            Priority = DeliveryPriority.High 
        };

        var orderB = new MockOrder 
        { 
            TotalVolume = 5.0, 
            Deadline = DateTime.Today.AddHours(12), // К 12 дня
            Location = new GeoLocation { X = 4, Y = 4 },
            Priority = DeliveryPriority.Standard 
        };

        Console.WriteLine("\n--- ФАЗА I: ПЛАНИРОВАНИЕ ЗАКАЗОВ ---");

        // Диспетчер обрабатывает заказы (разбивает и назначает)
        scheduler.ProcessOrder(orderA); // 14м³ => 2 поставки по 7м³
        scheduler.ProcessOrder(orderB); // 5м³ => 1 поставка

        Console.WriteLine($"\nВсего назначено слотов: {fleet.Sum(t => t.Schedule.Count)}");
        Console.WriteLine($"Расписание T01: {fleet.First(t => t.RegistrationNumber == "Т01").Schedule.Count} слотов.");

        // 4. Исполнение: Запуск Тикера
        Console.WriteLine("\n--- ФАЗА II: ИСПОЛНЕНИЕ И МОНИТОРИНГ ---");
        
        // Запускаем симулятор с шагом в 30 минут, пока смена не закончится
        while (engine.CurrentTime < ShiftInfo.ShiftWindow.End)
        {
            TimeSpan step = TimeSpan.FromMinutes(30);
            
            // Если осталось меньше 30 минут, берем остаток
            if (engine.CurrentTime.Add(step) > ShiftInfo.ShiftWindow.End) 
            {
                step = ShiftInfo.ShiftWindow.End - engine.CurrentTime;
            }
            
            Console.WriteLine($"\n[ТИКЕР] Время: {engine.CurrentTime.ToShortTimeString()} - {engine.CurrentTime.Add(step).ToShortTimeString()}");
            
            // Продвигаем время и генерируем события
            engine.AdvanceTime(step);

            // Имитация форс-мажора (ТОЛЬКО для T01 в 10:30)
            if (engine.CurrentTime.Hour == 10 && engine.CurrentTime.Minute == 30)
            {
                // 5. Каскадный перерасчет
                Console.WriteLine("\n--- ФАЗА III: КАСКАДНЫЙ ПЕРЕРАСЧЕТ (ФОРС-МАЖОР) ---");
                scheduler.ReportTruckBreakdown("Т01"); // T01 ломается в середине смены
            }

            // Если симуляция достигла конца, выходим
            if (engine.CurrentTime >= ShiftInfo.ShiftWindow.End) break;
        }

        Console.WriteLine("\n=== СИМУЛЯЦИЯ ЗАВЕРШЕНА ===");
    }
}

3. Анализ и Реакция Системы

Вот как система реагирует на сбой в Фазе III, используя ваши алгоритмы:

  1. Начальное Планирование:

    • Заказ A (14м³): Разбивается на два слота по 7м³.

      • Слот A1 и Слот A2, скорее всего, назначаются на Т01 и Т02, так как они ближе всего к заводу (низкий WeightDistance в Скоринге).

    • Заказ B (5м³): Назначается на машину с лучшим Скорингом (возможно, Т03, если у Т01/Т02 нет свободного окна, или Т02 благодаря близости и вместимости).

  2. Форс-мажор (10:30):

    • Событие: Диспетчер вызывает scheduler.ReportTruckBreakdown("Т01").

    • Реакция FleetScheduler (Алгоритм Б):

      • Т01 переводится в TruckStatus.OutOfService.

      • Все будущие слоты на Т01 (например, Слот A2) удаляются.

      • Удаленные IDelivery (Слот A2) добавляются в _emergencyQueue с Priority = Critical.

      • Запускается ProcessEmergencyQueue().

    • Перераспределение:

      • Алгоритм FindBestSlot ищет машину для потерянного слота A2.

      • Т01 игнорируется. Т03 (дальняя) может получить Слот A2, если Т02 занят, несмотря на большое время порожнего пробега, потому что Priority важнее.

  3. Завершение Поставки:

    • Когда наступает время Window.End для Т02, SimulationEngine генерирует OnDeliveryCompleted.

    • FleetScheduler ловит это событие и вызывает T02.UpdateStatus(TruckStatus.Idle), возвращая машину в пул для выполнения экстренных или новых заказов.

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