Skip to main content

Вариант 3

Анализ и формализация бизнес-процесса: Распределение грузовиков и каскадный перерасчёт поставок для перевозки жидкого цемента

Контекст и цель  

Цель — разработать понятную, устойчивую и масштабируемую систему распределения грузовиков по заказам в рамках фиксированной 12-часовой смены, с возможностью динамического каскадного перерасчёта временных окон (слотов) при возникновении сбоев (поломка, ДТП, задержка на разгрузке) или при появлении дополнительных свободных машин. Система должна гарантировать непересечение слотов на диаграмме Ганта, обеспечивать максимальную прозрачность через визуализацию, иметь событийную архитектуру с чёткими интерфейсами между сущностями и быть устойчивой к форс-мажорным отклонениям, приводящим к задержкам или выходу машины из работы.

Формализация: Сущности, ресурсы и ограничения  

Основные акторы (сущности):  
Заказ (Order) — содержит ID, объём в кубометрах, клиента, приоритет (от 1 до N) и желаемое окно доставки. Он инициирует потребность в транспортировке и может требовать нескольких поставок.  
Поставка (Shipment) — атомарная единица выполнения: один рейс одной машины. Имеет ID, объём, связь с заказом, статус (запланирована, в пути, выполнена, отменена), а также плановое и фактическое время погрузки и разгрузки.  
Грузовик (Truck) — основной мобильный ресурс. Обладает ID, вместимостью в кубометрах, текущей геопозицией, статусом (свободен, в работе, в ремонте, недоступен) и временем, когда он возвращается к заводу. Может совершать несколько поставок за смену.  
Смена (Shift) — временной горизонт (например, с 06:00 до 18:00), в котором должны быть завершены все поставки. Включает точку погрузки (бетонный завод).  
Диспетчер (Dispatcher) — логическая сущность, которая инициирует расчёт, подтверждает перерасчёт и реагирует на события. В системе выступает как активатор процессов.

Ключевые ограничения:  

1. Время жизни бетона: жидкий цемент начинает твердеть после загрузки. Стандартное «окно жизнеспособности» — 90 минут от момента погрузки до начала разгрузки. Это жёсткое ограничение на продолжительность поставки.  
2. Непересекаемость слотов: на диаграмме Ганта для одного грузовика не может быть двух активных поставок одновременно.  
3. Фиксированный горизонт: все поставки должны быть закончены (разгружены) до конца смены.  
4. Форс-мажор: любой сбой (поломка, ДТП, задержка) приводит к одному из двух исходов — либо машина выбывает полностью до конца смены, либо возвращается позже, чем предполагалось, и её последующие слоты смещаются или отменяются.

Основные события и интерфейсы  


Система строится на событиях. Примеры ключевых событий: OrderCreated — создание заказа, триггер для разбиения на поставки; ShipmentScheduled — поставка назначена на машину и слот; TruckBreakdown — машина вышла из строя, инициирует каскадный перерасчёт; NewTruckAvailable — появилась свободная машина, может запустить оптимизацию; ShipmentCompleted — поставка завершена, обновляется статус машины.  

Интерфейсы обеспечивают взаимодействие: ITruckSelector выбирает грузовик по критериям (близость к заводу, вместимость, приоритет клиента); IScheduleEngine управляет расписанием — назначает, пересчитывает, проверяет конфликты; IEventPublisher и IEventListener обеспечивают обмен событиями между компонентами (например, через паттерны Mediator или Observer).

Алгоритм первоначального распределения (прогнозное планирование)  

Шаг 1. Разбиение заказов на поставки. Для каждого заказа с объёмом V и максимальной вместимостью машины C количество поставок равно округлённому вверх значению V / C. Каждая поставка имеет объём не больше C.  

Шаг 2. Назначение поставок на машины с помощью жадного алгоритма с приоритетами. Для каждой поставки (отсортированной сначала по приоритету заказа, затем по требуемому времени доставки):  
— фильтруется пул доступных машин (статус «свободен» или «станет свободен до конца смены»);  
— кандидаты сортируются по расстоянию до завода (ближе — лучше), затем по вместимости (должна быть достаточной), и при равенстве — по приоритету заказа;  
— выбирается первая подходящая машина и назначается на ближайший свободный временной слот, который укладывается в смену, учитывает полный цикл (погрузка → транзит → разгрузка → возврат) и не нарушает 90-минутное окно жизнеспособности;  
— в диаграмму Ганта добавляется новый слот.

Алгоритм каскадного перерасчёта (реактивное планирование)  
Активируется событием: TruckBreakdown, ShipmentDelay или NewTruckAvailable.  

Шаги:  
1. Идентификация всех затронутых поставок — это поставки, назначенные на повреждённую машину после точки сбоя, а также поставки других машин, которые зависят от её завершения (например, по времени на одном объекте).  
2. Освобождение соответствующих слотов в расписании.  
3. Перераспределение: список затронутых поставок сортируется по приоритету и требуемому времени, затем к каждой применяется тот же алгоритм выбора машины, но только по свободным слотам. Если машина выбыла — поставки назначаются на другие. Если появилась новая машина — система может перераспределить часть нагрузки для оптимизации.  
4. Валидация новых слотов: проверка на пересечение, укладку в 90 минут и завершение до конца смены.  
5. Публикация события ScheduleRecalculated — обновление диаграммы Ганта и уведомление заинтересованных сторон.  

Важно: перерасчёт должен быть ограничен по глубине или времени выполнения, чтобы не зависнуть в случае множественных сбоев.

Подводные камни и риски  


Жёсткое 90-минутное окно может привести к выбросу бетона и убыткам. Смягчение: использовать динамическую модель «живучести», учитывающую состав смеси и температуру, а также закладывать временные буферы.  

Неточное время мойки или разгрузки вызывает накопление задержек и срыв последующих слотов. Смягчение: использовать исторические данные для прогноза длительности операций на каждом объекте.  

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

Перегруз диспетчера — он не успевает реагировать на множество сбоев. Смягчение: система не только пересчитывает, но и предлагает 1–2 лучших варианта на выбор.  

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

Рекомендации по архитектуре  
Система должна быть построена на событийной модели с использованием паттернов Mediator, Observer или Reactor. Для хранения полной истории изменений рекомендуется использовать event sourcing (например, EventStoreDB). Визуализация через диаграмму Ганта обязательна — она должна поддерживать drag-and-drop и автоматически подсвечивать конфликты.  

Интеграции: с GPS/телематикой для отслеживания статуса машин, с мобильным приложением водителя для подтверждения операций, с ERP/CRM для синхронизации заказов и приоритетов.  

Технологии: бэкенд на .NET C# с паттернами Strategy (для гибкой смены алгоритмов выбора машины) и Mediator (для обработки событий); фронтенд на Svelte/SvelteKit для интерактивной диаграммы; инфраструктура — Docker и Redis (для кэширования текущего состояния расписания).

Заключение  


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

 

Алгоритмы на C# в соответствии с бизнес-процессом. Код построен на принципах SOLID, использует событийную модель, интерфейсы и поддерживает каскадный перерасчёт.

1. Базовые сущности и интерфейсы

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

// ==== СУЩНОСТИ ====

public class Order
{
    public Guid Id { get; set; }
    public double VolumeCubicMeters { get; set; }
    public int Priority { get; set; } // Чем выше, тем важнее
    public DateTime? PreferredDeliveryWindowStart { get; set; }
    public DateTime? PreferredDeliveryWindowEnd { get; set; }
    public string CustomerId { get; set; }
}

public enum ShipmentStatus
{
    Scheduled,
    InTransit,
    Completed,
    Cancelled,
    Failed
}

public class Shipment
{
    public Guid Id { get; set; }
    public Guid OrderId { get; set; }
    public double VolumeCubicMeters { get; set; }
    public ShipmentStatus Status { get; set; }
    public DateTime LoadTimePlanned { get; set; }
    public DateTime UnloadTimePlanned { get; set; }
    public DateTime? LoadTimeActual { get; set; }
    public DateTime? UnloadTimeActual { get; set; }
    public Guid? AssignedTruckId { get; set; }
}

public enum TruckStatus
{
    Available,
    InService,
    Broken,
    Unavailable
}

public class Truck
{
    public Guid Id { get; set; }
    public double CapacityCubicMeters { get; set; }
    public TruckStatus Status { get; set; }
    public DateTime? NextAvailableTime { get; set; } // Когда машина освободится
    public double DistanceToPlantKm { get; set; } // Расстояние до завода
}

// ==== ИНТЕРФЕЙСЫ ====

public interface ITruckSelector
{
    Truck? SelectTruck(IList<Truck> availableTrucks, Shipment shipment, DateTime shiftStart, DateTime shiftEnd);
}

public interface IScheduleEngine
{
    bool TryScheduleShipment(Shipment shipment, Truck truck, DateTime shiftStart, DateTime shiftEnd, out DateTime loadTime, out DateTime unloadTime);
    void RescheduleAffectedShipments(Guid? brokenTruckId = null, Truck? newTruck = null);
    IList<Shipment> GetUnscheduledShipments();
    void MarkShipmentCompleted(Guid shipmentId, DateTime actualUnloadTime);
}

public interface IEventPublisher
{
    void Publish<T>(T @event) where T : class;
}

public interface IEventListener<in T> where T : class
{
    void Handle(T @event);
}


2. Реализация ядра: ScheduleEngine

 

public class ScheduleEngine : IScheduleEngine
{
    private readonly IList<Shipment> _shipments = new List<Shipment>();
    private readonly IList<Truck> _trucks = new List<Truck>();
    private readonly ITruckSelector _truckSelector;
    private readonly IEventPublisher _eventPublisher;
    private readonly DateTime _shiftStart;
    private readonly DateTime _shiftEnd;
    private readonly double _concreteViabilityMinutes = 90;

    public ScheduleEngine(
        IEnumerable<Truck> trucks,
        DateTime shiftStart,
        DateTime shiftEnd,
        ITruckSelector truckSelector,
        IEventPublisher eventPublisher)
    {
        _trucks = trucks.ToList();
        _shiftStart = shiftStart;
        _shiftEnd = shiftEnd;
        _truckSelector = truckSelector;
        _eventPublisher = eventPublisher;
    }

    public void AddOrder(Order order)
    {
        var shipments = SplitOrderIntoShipments(order);
        foreach (var shipment in shipments)
        {
            _shipments.Add(shipment);
        }
        // Триггер планирования
        _eventPublisher.Publish(new OrderCreatedEvent(order.Id));
    }

    private IList<Shipment> SplitOrderIntoShipments(Order order)
    {
        var shipments = new List<Shipment>();
        var remainingVolume = order.VolumeCubicMeters;

        while (remainingVolume > 0)
        {
            var truck = _trucks.OrderByDescending(t => t.CapacityCubicMeters).First();
            var shipmentVolume = Math.Min(remainingVolume, truck.CapacityCubicMeters);

            shipments.Add(new Shipment
            {
                Id = Guid.NewGuid(),
                OrderId = order.Id,
                VolumeCubicMeters = shipmentVolume,
                Status = ShipmentStatus.Scheduled
            });

            remainingVolume -= shipmentVolume;
        }

        return shipments;
    }

    public bool TryScheduleShipment(Shipment shipment, Truck truck, DateTime shiftStart, DateTime shiftEnd, out DateTime loadTime, out DateTime unloadTime)
    {
        loadTime = default;
        unloadTime = default;

        // Определяем, когда машина может начать
        var earliestStart = truck.NextAvailableTime ?? shiftStart;
        if (earliestStart < shiftStart) earliestStart = shiftStart;

        // Время погрузки — сразу, как только машина свободна
        loadTime = earliestStart;

        // Примерное время в пути: 10 мин на км до клиента + 15 мин на разгрузку
        // Здесь можно использовать внешний сервис расчёта маршрута
        var travelTimeMinutes = 20; // упрощённо
        var unloadDurationMinutes = 15;

        var estimatedUnloadStart = loadTime.AddMinutes(travelTimeMinutes);
        var estimatedUnloadEnd = estimatedUnloadStart.AddMinutes(unloadDurationMinutes);

        // Проверка окна жизнеспособности
        if ((estimatedUnloadStart - loadTime).TotalMinutes > _concreteViabilityMinutes)
            return false;

        // Проверка укладки в смену
        if (estimatedUnloadEnd > shiftEnd)
            return false;

        unloadTime = estimatedUnloadEnd;
        return true;
    }

    // Основной метод первоначального планирования
    public void InitialSchedule()
    {
        var unscheduled = _shipments.Where(s => s.Status == ShipmentStatus.Scheduled && s.AssignedTruckId == null)
                                    .OrderByDescending(s => GetOrderPriority(s.OrderId)) // сначала высокий приоритет
                                    .ThenBy(s => s.UnloadTimePlanned) // затем по времени
                                    .ToList();

        foreach (var shipment in unscheduled)
        {
            var availableTrucks = _trucks.Where(t => t.Status == TruckStatus.Available || t.Status == TruckStatus.InService).ToList();
            var selectedTruck = _truckSelector.SelectTruck(availableTrucks, shipment, _shiftStart, _shiftEnd);

            if (selectedTruck != null)
            {
                if (TryScheduleShipment(shipment, selectedTruck, _shiftStart, _shiftEnd, out var loadTime, out var unloadTime))
                {
                    shipment.LoadTimePlanned = loadTime;
                    shipment.UnloadTimePlanned = unloadTime;
                    shipment.AssignedTruckId = selectedTruck.Id;
                    selectedTruck.NextAvailableTime = unloadTime.AddMinutes(10); // +10 мин на возврат и мойку
                    selectedTruck.Status = TruckStatus.InService;

                    _eventPublisher.Publish(new ShipmentScheduledEvent(shipment, selectedTruck.Id));
                }
            }
        }
    }

    private int GetOrderPriority(Guid orderId)
    {
        // Здесь можно загрузить из OrderRepository
        return 1; // заглушка
    }

    public void RescheduleAffectedShipments(Guid? brokenTruckId = null, Truck? newTruck = null)
    {
        if (newTruck != null)
        {
            _trucks.Add(newTruck);
        }

        var affectedShipments = new List<Shipment>();

        if (brokenTruckId.HasValue)
        {
            // Машина вышла из строя — все её будущие поставки отменяются и требуют переназначения
            var failedShipments = _shipments
                .Where(s => s.AssignedTruckId == brokenTruckId && s.Status == ShipmentStatus.Scheduled)
                .ToList();

            foreach (var s in failedShipments)
            {
                s.Status = ShipmentStatus.Cancelled;
                s.AssignedTruckId = null;
                affectedShipments.Add(s);
            }

            // Найти машину и пометить как сломанную
            var truck = _trucks.FirstOrDefault(t => t.Id == brokenTruckId);
            if (truck != null)
            {
                truck.Status = TruckStatus.Broken;
                truck.NextAvailableTime = null;
            }
        }

        // Перепланируем все неназначенные поставки
        var unscheduled = _shipments
            .Where(s => s.Status == ShipmentStatus.Scheduled && s.AssignedTruckId == null)
            .OrderByDescending(s => GetOrderPriority(s.OrderId))
            .ThenBy(s => s.UnloadTimePlanned)
            .ToList();

        foreach (var shipment in unscheduled)
        {
            var availableTrucks = _trucks
                .Where(t => t.Status == TruckStatus.Available || t.Status == TruckStatus.InService)
                .ToList();

            var selectedTruck = _truckSelector.SelectTruck(availableTrucks, shipment, _shiftStart, _shiftEnd);

            if (selectedTruck != null)
            {
                if (TryScheduleShipment(shipment, selectedTruck, _shiftStart, _shiftEnd, out var loadTime, out var unloadTime))
                {
                    shipment.LoadTimePlanned = loadTime;
                    shipment.UnloadTimePlanned = unloadTime;
                    shipment.AssignedTruckId = selectedTruck.Id;
                    selectedTruck.NextAvailableTime = unloadTime.AddMinutes(10);
                    selectedTruck.Status = TruckStatus.InService;

                    _eventPublisher.Publish(new ShipmentRescheduledEvent(shipment, selectedTruck.Id));
                }
            }
        }

        _eventPublisher.Publish(new ScheduleRecalculatedEvent());
    }

    public IList<Shipment> GetUnscheduledShipments()
    {
        return _shipments.Where(s => s.AssignedTruckId == null && s.Status == ShipmentStatus.Scheduled).ToList();
    }

    public void MarkShipmentCompleted(Guid shipmentId, DateTime actualUnloadTime)
    {
        var shipment = _shipments.FirstOrDefault(s => s.Id == shipmentId);
        if (shipment != null)
        {
            shipment.Status = ShipmentStatus.Completed;
            shipment.UnloadTimeActual = actualUnloadTime;

            var truck = _trucks.FirstOrDefault(t => t.Id == shipment.AssignedTruckId);
            if (truck != null)
            {
                truck.NextAvailableTime = actualUnloadTime.AddMinutes(10);
            }

            _eventPublisher.Publish(new ShipmentCompletedEvent(shipmentId, actualUnloadTime));
        }
    }
}

3. Реализация выбора грузовика

public class DefaultTruckSelector : ITruckSelector
{
    public Truck? SelectTruck(IList<Truck> availableTrucks, Shipment shipment, DateTime shiftStart, DateTime shiftEnd)
    {
        if (availableTrucks == null || !availableTrucks.Any())
            return null;

        // Фильтр по вместимости
        var candidates = availableTrucks
            .Where(t => t.CapacityCubicMeters >= shipment.VolumeCubicMeters)
            .ToList();

        if (!candidates.Any())
            return null;

        // Сортировка: сначала по расстоянию до завода (ближе — лучше)
        // Затем по времени доступности (раньше — лучше)
        var best = candidates
            .OrderBy(t => t.DistanceToPlantKm)
            .ThenBy(t => t.NextAvailableTime ?? shiftStart)
            .FirstOrDefault();

        return best;
    }
}

4. События и обработка

// ==== СОБЫТИЯ ====

public class OrderCreatedEvent
{
    public Guid OrderId { get; }
    public OrderCreatedEvent(Guid orderId) => OrderId = orderId;
}

public class ShipmentScheduledEvent
{
    public Shipment Shipment { get; }
    public Guid TruckId { get; }
    public ShipmentScheduledEvent(Shipment shipment, Guid truckId)
    {
        Shipment = shipment;
        TruckId = truckId;
    }
}

public class ShipmentRescheduledEvent
{
    public Shipment Shipment { get; }
    public Guid TruckId { get; }
    public ShipmentRescheduledEvent(Shipment shipment, Guid truckId)
    {
        Shipment = shipment;
        TruckId = truckId;
    }
}

public class ShipmentCompletedEvent
{
    public Guid ShipmentId { get; }
    public DateTime ActualUnloadTime { get; }
    public ShipmentCompletedEvent(Guid shipmentId, DateTime actualUnloadTime)
    {
        ShipmentId = shipmentId;
        ActualUnloadTime = actualUnloadTime;
    }
}

public class ScheduleRecalculatedEvent { }

// ==== ПРОСТОЙ ПУБЛИШЕР (МЕДИАТОР) ====

public class SimpleEventPublisher : IEventPublisher
{
    private readonly Dictionary<Type, List<object>> _handlers = new();

    public void Subscribe<T>(IEventListener<T> handler) where T : class
    {
        var type = typeof(T);
        if (!_handlers.ContainsKey(type))
            _handlers[type] = new List<object>();
        _handlers[type].Add(handler);
    }

    public void Publish<T>(T @event) where T : class
    {
        var type = @event.GetType();
        if (_handlers.TryGetValue(type, out var handlers))
        {
            foreach (IEventListener<T> handler in handlers.OfType<IEventListener<T>>())
            {
                handler.Handle(@event);
            }
        }
    }
}

5. Пример использования

// Инициализация
var trucks = new List<Truck>
{
    new Truck { Id = Guid.NewGuid(), CapacityCubicMeters = 5, DistanceToPlantKm = 2, Status = TruckStatus.Available },
    new Truck { Id = Guid.NewGuid(), CapacityCubicMeters = 5, DistanceToPlantKm = 5, Status = TruckStatus.Available },
};

var shiftStart = new DateTime(2025, 12, 5, 6, 0, 0);
var shiftEnd = shiftStart.AddHours(12);

var eventPublisher = new SimpleEventPublisher();
var truckSelector = new DefaultTruckSelector();
var scheduler = new ScheduleEngine(trucks, shiftStart, shiftEnd, truckSelector, eventPublisher);

// Добавление заказа
var order = new Order
{
    Id = Guid.NewGuid(),
    VolumeCubicMeters = 15,
    Priority = 1,
    CustomerId = "CUST-001"
};

scheduler.AddOrder(order);
scheduler.InitialSchedule();

// Симуляция поломки
var brokenTruckId = trucks[0].Id;
scheduler.RescheduleAffectedShipments(brokenTruckId: brokenTruckId);

// Симуляция появления новой машины
var newTruck = new Truck
{
    Id = Guid.NewGuid(),
    CapacityCubicMeters = 6,
    DistanceToPlantKm = 1,
    Status = TruckStatus.Available
};
scheduler.RescheduleAffectedShipments(newTruck: newTruck);

 Особенности реализации

1. Гибкость выбора машины: через `ITruckSelector` легко подключить другие стратегии (например, с учётом приоритета клиента).
2. Поддержка каскада: `RescheduleAffectedShipments` перепланирует **все** неназначенные поставки, а не только прямые.
3. Окно жизнеспособности: жёстко ограничено 90 минутами, но легко настраивается.
4. Событийная модель: все действия публикуют события, на которые можно подписаться (например, для обновления UI или логирования).
5. Безопасность: используются `Guid`, проверки `null`, защита от выхода за границы смены.

Код готов к интеграции в .NET-приложение (ASP.NET Core, консоль, Windows Forms и т.д.) и может быть расширен под ваши данные (загрузка заказов из БД, расчёт маршрутов через внешние API и т.п.).