Compare commits
1 Commits
refactor_2
...
very_hard_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f916d764c0 |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description: A description of your rule
|
|
||||||
---
|
|
||||||
Ты senior python-разработчик
|
|
||||||
У тебя 5-лет опыта работы с библиотекой ortools
|
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
136
PLAN.md
136
PLAN.md
@@ -1,136 +0,0 @@
|
|||||||
# 📋 ПЛАН РАБОТ: Модульность конфигурации
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
Перенести все константы (настройки) в отдельный файл `config.py` для удобства редактирования человеком, не знакомым с Python.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 1: Создание файла конфигурации
|
|
||||||
|
|
||||||
**Файл:** `config.py`
|
|
||||||
|
|
||||||
**Что перенести:**
|
|
||||||
- **Настройки дня**
|
|
||||||
- Год (`year = 2026`)
|
|
||||||
- Месяц (`month = 4`)
|
|
||||||
|
|
||||||
- **Смены (`SHIFTS`)**
|
|
||||||
- С 06:00 до 14:30 (смена 0)
|
|
||||||
- С 08:00 до 16:30 (смена 1)
|
|
||||||
- С 11:30 до 20:00 (смена 2)
|
|
||||||
- С 12:30 до 21:00 (смена 3)
|
|
||||||
|
|
||||||
- **Операторы (`OPERATORS`)**
|
|
||||||
- Иванов
|
|
||||||
- Алексеев
|
|
||||||
- Сидоров
|
|
||||||
- Козобородов
|
|
||||||
- Трудяшкина
|
|
||||||
- Петрова
|
|
||||||
|
|
||||||
- **Предпочтения операторов (`OPERATOR_PREFERENCES`)**
|
|
||||||
- Иванов: 10, 13 апреля
|
|
||||||
- Алексеев: понедельники
|
|
||||||
- Сидоров: 25, 26 апреля
|
|
||||||
- Козобородов: смена 2 (11:30–20:00)
|
|
||||||
- Трудяшкина: 4 апреля
|
|
||||||
- Петрова: 8, 29 апреля
|
|
||||||
|
|
||||||
**Как редактировать:**
|
|
||||||
1. Откройте `config.py` в текстовом редакторе
|
|
||||||
2. Изменяйте значения в квадратных скобках или кавычках
|
|
||||||
3. Для добавления/удаления дней используйте списки `[25, 26]`
|
|
||||||
4. Сохраните файл
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 2: Обновление `models.py`
|
|
||||||
|
|
||||||
**Что делать:**
|
|
||||||
- Удалить все константы из `models.py`
|
|
||||||
- Добавить импорт: `from config import *`
|
|
||||||
- Удалить определения `SHIFTS`, `OPERATORS`, `OPERATOR_PREFERENCES`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 3: Обновление `main.py`
|
|
||||||
|
|
||||||
**Что делать:**
|
|
||||||
- Удалить встроенную генерацию дат
|
|
||||||
- Использовать импорт из `config.py`
|
|
||||||
- Добавить вывод в CSV
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 4: Создание класса предпочтений операторов
|
|
||||||
|
|
||||||
**Файл:** `models.py` (или отдельный файл `preferences.py`)
|
|
||||||
|
|
||||||
**Что сделать:**
|
|
||||||
- Создать класс `OperatorPreferences` для конструирования и управления предпочтениями операторов.
|
|
||||||
- Реализовать цепочку методов (builder pattern) for удобного создания предпочтений.
|
|
||||||
- Поддержать следующие типы предпочтений:
|
|
||||||
- Желаемый рабочий день (конкретные даты месяца)
|
|
||||||
- Желаемая смена (индекс смены 0-3)
|
|
||||||
- Желаемый день недели для работы (понедельник, вторник, ...)
|
|
||||||
- Желаемый выходной (конкретные даты месяца)
|
|
||||||
- Реализовать метод `__str__` для отладки.
|
|
||||||
- Обеспечить экспорт предпочтений в формат, пригодный для `config.py`.
|
|
||||||
|
|
||||||
**Требования к реализации:**
|
|
||||||
- Класс должен позволять конструировать предпочтения через цепочку методов.
|
|
||||||
- Поддерживать три типа предпочтений (как указано выше).
|
|
||||||
- Экспортировать предпочтения в формат, пригодный для `config.py`.
|
|
||||||
- Поддерживать метод `__str__` для отладки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 5: Создание документации
|
|
||||||
|
|
||||||
**Файл:** `USER_GUIDE.md`
|
|
||||||
|
|
||||||
**Что включить:**
|
|
||||||
- Как изменить даты
|
|
||||||
- Как добавить/удалить операторов
|
|
||||||
- Как изменить смены
|
|
||||||
- Как добавить новые предпочтения
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 6: Тестирование
|
|
||||||
|
|
||||||
**Проверить:**
|
|
||||||
1. Запуск `python main.py`
|
|
||||||
2. Корректное считывание дат из `config.py`
|
|
||||||
3. Генерация расписания
|
|
||||||
4. Вывод в CSV
|
|
||||||
5. Корректная работа класса `OperatorPreferences`
|
|
||||||
6. Экспорт предпочтений в `config.py`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 7: Написание тестов
|
|
||||||
|
|
||||||
**Файл:** `tests/test_all.py`
|
|
||||||
|
|
||||||
**Что сделать:**
|
|
||||||
- Создать тесты для всех модулей проекта
|
|
||||||
- Использовать pytest для запуска тестов
|
|
||||||
- Проверить корректность работы всех компонентов
|
|
||||||
- Добавить тесты для edge-кейсов
|
|
||||||
|
|
||||||
**Структура тестов:**
|
|
||||||
- `test_config.py` - тесты для конфигурации
|
|
||||||
- `test_models.py` - тесты для моделей и предпочтений
|
|
||||||
- `test_main.py` - тесты для основного скрипта
|
|
||||||
- `test_integration.py` - интеграционные тесты
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Результат
|
|
||||||
- Конфигурация в одном файле `config.py`
|
|
||||||
- Простое редактирование без знания Python
|
|
||||||
- Четкая документация в `USER_GUIDE.md`
|
|
||||||
- Гибкая система предпочтений через класс `OperatorPreferences`
|
|
||||||
- Полноценный набор тестов для всех компонентов
|
|
||||||
|
|
||||||
106
README.md
106
README.md
@@ -7,10 +7,108 @@
|
|||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
├── main.py # Основная логика с генерацией графика
|
OperatorSchedule/
|
||||||
├── shift_schedule.csv # Результат (заполненный график)
|
├── src/ # Основной исходный код
|
||||||
├── README.md # Документация
|
│ ├── __init__.py # Корневой пакет
|
||||||
└── .continue/ # Система контроля версий
|
│ ├── app.py # Точка входа (FastAPI, если веб-интерфейс)
|
||||||
|
│ ├── config.py # Конфигурация системы
|
||||||
|
│ ├── exceptions.py # Исключения и ошибки
|
||||||
|
│ ├── logging_config.py # Настройка логирования
|
||||||
|
│ │
|
||||||
|
│ ├── core/ # Ядро системы
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── engine.py # Основной класс планировщика (SchedulerEngine)
|
||||||
|
│ │ ├── solver.py # Орто-модель CP-SAT
|
||||||
|
│ │ ├── validator.py # Валидатор результатов
|
||||||
|
│ │ └── events.py # События системы (логи, уведомления)
|
||||||
|
│ │
|
||||||
|
│ ├── domains/ # Доменные сущности
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── models/ # Модели данных
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── operator.py # Класс Operator (оператор)
|
||||||
|
│ │ │ ├── shift.py # класс Shift (смена)
|
||||||
|
│ │ │ ├── schedule.py # класс Schedule (график)
|
||||||
|
│ │ │ └── preference.py # класс Preference (предпочтение)
|
||||||
|
│ │ ├── repositories/ # Интерфейсы доступа к данным
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── operator_repo.py
|
||||||
|
│ │ │ ├── shift_repo.py
|
||||||
|
│ │ │ └── schedule_repo.py
|
||||||
|
│ │ └── services/ # Бизнес-логика
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── generator.py # Генератор графиков
|
||||||
|
│ │ ├── optimizer.py # Оптимизатор
|
||||||
|
│ │ └── conflict_resolver.py # Ресольвер конфликтов
|
||||||
|
│ │
|
||||||
|
│ ├── constraints/ # Управление ограничениями
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── base_constraint.py # Базовый класс ограничения
|
||||||
|
│ │ ├── hard_constraints.py # Жёсткие ограничения
|
||||||
|
│ │ ├── soft_constraints.py # Мягкие ограничения (предпочтения)
|
||||||
|
│ │ └── global_constraints.py # Глобальные ограничения
|
||||||
|
│ │
|
||||||
|
│ ├── exporters/ # Экспорт данных
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── base_exporter.py # Базовый класс экспортера
|
||||||
|
│ │ ├── csv_exporter.py
|
||||||
|
│ │ ├── excel_exporter.py
|
||||||
|
│ │ ├── html_exporter.py
|
||||||
|
│ │ └── json_exporter.py
|
||||||
|
│ │
|
||||||
|
│ ├── ui/ # Веб-интерфейс (опционально)
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── routes/ # Маршруты API
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── index.py # Главная страница
|
||||||
|
│ │ │ ├── config.py # Конфигурация API
|
||||||
|
│ │ │ └── export.py # Экспорт графики
|
||||||
|
│ │ ├── templates/ # Jinja2 шаблоны
|
||||||
|
│ │ └── static/ # CSS, JS
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # Вспомогательные модули
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── calendar.py # Работа с календарями
|
||||||
|
│ ├── validators.py # Валидаторы данных
|
||||||
|
│ └── formatters.py # Форматтеры вывода
|
||||||
|
│
|
||||||
|
├── tests/ # Тесты
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── conftest.py # Фикстуры pytest
|
||||||
|
│ ├── unit/ # Unit-тесты
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── test_domains.py
|
||||||
|
│ │ ├── test_constraints.py
|
||||||
|
│ │ └── test_exporters.py
|
||||||
|
│ ├── integration/ # Интеграционные тесты
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── test_scheduler.py
|
||||||
|
│ └── fixtures/ # Фикстуры данных
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── operators.json # JSON с операторами
|
||||||
|
│ └── shifts.json # JSON со сменами
|
||||||
|
│
|
||||||
|
├── docs/ # Документация
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── CHANGELOG.md # История изменений
|
||||||
|
│ ├── API.md # Документация API
|
||||||
|
│ └── architecture.md # Архитектурный обзор
|
||||||
|
│
|
||||||
|
├── config/ # Конфигурационные файлы
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── default.yaml # Стандартная конфигурация
|
||||||
|
│ └── development.yaml # Разработка
|
||||||
|
│
|
||||||
|
├── examples/ # Примеры использования
|
||||||
|
│ ├── basic.py # Базовый пример
|
||||||
|
│ ├── custom_constraints.py# Пример с кастомными ограничениями
|
||||||
|
│ └── multi_department.py # Многоотраслевой пример
|
||||||
|
│
|
||||||
|
├── requirements.txt # Зависимости Python
|
||||||
|
├── requirements-dev.txt # Зависимости для разработки
|
||||||
|
├── setup.py # Установка пакета
|
||||||
|
├── pytest.ini # Настройки pytest
|
||||||
|
└── .gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
60
config.py
60
config.py
@@ -1,60 +0,0 @@
|
|||||||
"""
|
|
||||||
config.py - Настройки графика смен
|
|
||||||
Редактируйте этот файл БЕЗ знания Python!
|
|
||||||
"""
|
|
||||||
|
|
||||||
# 📅 НАСТРОЙКИ ДАТ
|
|
||||||
YEAR = 2026 # Год
|
|
||||||
MONTH = 4 # Месяц (апрель)
|
|
||||||
|
|
||||||
# 🕒 СМЕНЫ (время начала, время окончания)
|
|
||||||
# format: (начало_часа, окончание_часа)
|
|
||||||
SHIFTS = [
|
|
||||||
(6, 14.5), # Смена 0: 06:00 - 14:30
|
|
||||||
(8, 16.5), # Смена 1: 08:00 - 16:30
|
|
||||||
(11.5, 20), # Смена 2: 11:30 - 20:00
|
|
||||||
(12.5, 21), # Смена 3: 12:30 - 21:00
|
|
||||||
]
|
|
||||||
|
|
||||||
# 👷 ИМЕНА ОПЕРАТОРОВ
|
|
||||||
# Меняйте здесь имена операторов!
|
|
||||||
OPERATORS = [
|
|
||||||
"Иванов",
|
|
||||||
"Алексеев",
|
|
||||||
"Сидоров",
|
|
||||||
"Козобородов",
|
|
||||||
"Трудяшкина",
|
|
||||||
"Петрова",
|
|
||||||
]
|
|
||||||
|
|
||||||
# 💖 ПРЕДПОЧТЕНИЯ ОПЕРАТОРОВ
|
|
||||||
# Формат: {имя_оператора: объект_предпочтений}
|
|
||||||
# Для создания предпочтений используйте класс OperatorPreferences из models.py
|
|
||||||
# Пример:
|
|
||||||
# from models import OperatorPreferences, DayOfWeek
|
|
||||||
# prefs = OperatorPreferences.create("Иванов") \
|
|
||||||
# .work_on_date(10) \
|
|
||||||
# .work_on_date(13) \
|
|
||||||
# .build()
|
|
||||||
# OPERATOR_PREFERENCES = {"Иванов": prefs}
|
|
||||||
|
|
||||||
# Для удобства, здесь приведены примеры создания предпочтений через словарь,
|
|
||||||
# который будет автоматически преобразован в объект OperatorPreferences в models.py.
|
|
||||||
# Это позволяет редактировать файл без знания Python, используя простые структуры данных.
|
|
||||||
|
|
||||||
OPERATOR_PREFERENCES = {
|
|
||||||
"Иванов": {"dates": [10, 13]}, # 10 и 13 апреля
|
|
||||||
"Алексеев": {"day_of_week": "понедельник"}, # только понедельники
|
|
||||||
"Сидоров": {"dates": [25, 26]}, # 25 и 26 апреля
|
|
||||||
"Козобородов": {"shift": 2}, # только смена 2 (11:30–20:00)
|
|
||||||
"Трудяшкина": {"dates": [4]}, # 4 апреля
|
|
||||||
"Петрова": {"dates": [8, 29]}, # 8 и 29 апреля
|
|
||||||
}
|
|
||||||
|
|
||||||
# 📝 ПОЯСНЕНИЯ ДЛЯ РЕДАКТИРОВАНИЯ:
|
|
||||||
# - Дни месяца указываются в списке: {"dates": [10, 13]} = 10 и 13 апреля
|
|
||||||
# - "понедельник" (или "monday") означает работать только в понедельник
|
|
||||||
# - Число в "shift": 0-3 указывает на номер смены (0 = первая смена)
|
|
||||||
# - Для добавления/удаления дней меняйте списки в квадратных скобках
|
|
||||||
# - Для изменения имени оператора меняйте строку в OPERATORS
|
|
||||||
# - Для установки дня, когда оператор НЕ работает, используйте: {"off_dates": [5]}
|
|
||||||
94
constraints/__init__.py
Normal file
94
constraints/__init__.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Constraints package."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Dict, Any, Optional, Union
|
||||||
|
|
||||||
|
from constraints.base_constraint import Constraint, ConstraintViolation, ConstraintType
|
||||||
|
from constraints.hard_constraints import (
|
||||||
|
SkillRequirementConstraint,
|
||||||
|
ShiftCoverageConstraint,
|
||||||
|
MaxWorkTimeConstraint,
|
||||||
|
DepartmentAssignmentConstraint,
|
||||||
|
MinCoverageConstraint,
|
||||||
|
ConflictPreventionConstraint,
|
||||||
|
create_hard_constraints
|
||||||
|
)
|
||||||
|
from constraints.soft_constraints import (
|
||||||
|
OperatorPreferenceConstraint,
|
||||||
|
DayAvailabilityConstraint,
|
||||||
|
WorkBalanceConstraint,
|
||||||
|
ContinuityPreference,
|
||||||
|
SeniorityPriorityConstraint,
|
||||||
|
create_soft_constraints
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConstraintSet:
|
||||||
|
"""Container for all constraints (hard and soft)."""
|
||||||
|
hard_constraints: List[Constraint]
|
||||||
|
soft_constraints: List[Constraint]
|
||||||
|
scenario: Dict[str, Any]
|
||||||
|
|
||||||
|
def validate(self, context: Dict[str, Any]) -> List[ConstraintViolation]:
|
||||||
|
"""Validate all constraints and return violations."""
|
||||||
|
violations = []
|
||||||
|
|
||||||
|
# Check hard constraints
|
||||||
|
for constraint in self.hard_constraints:
|
||||||
|
if constraint.enabled:
|
||||||
|
violation = constraint.get_violation(context)
|
||||||
|
if violation:
|
||||||
|
violations.append(violation)
|
||||||
|
|
||||||
|
# Check soft constraints (only warn, don't fail)
|
||||||
|
for constraint in self.soft_constraints:
|
||||||
|
if constraint.enabled:
|
||||||
|
violation = constraint.get_violation(context)
|
||||||
|
if violation:
|
||||||
|
violations.append(violation)
|
||||||
|
|
||||||
|
return violations
|
||||||
|
|
||||||
|
def get_enabled_hard_constraints(self) -> List[Constraint]:
|
||||||
|
"""Get all enabled hard constraints."""
|
||||||
|
return [c for c in self.hard_constraints if c.enabled]
|
||||||
|
|
||||||
|
def get_enabled_soft_constraints(self) -> List[Constraint]:
|
||||||
|
"""Get all enabled soft constraints."""
|
||||||
|
return [c for c in self.soft_constraints if c.enabled]
|
||||||
|
|
||||||
|
def get_disabled_constraints(self) -> List[Constraint]:
|
||||||
|
"""Get all disabled constraints."""
|
||||||
|
return [c for c in self.hard_constraints + self.soft_constraints if not c.enabled]
|
||||||
|
|
||||||
|
def enable_constraint(self, constraint_id: str) -> bool:
|
||||||
|
"""Enable a constraint by ID."""
|
||||||
|
for constraint in self.hard_constraints + self.soft_constraints:
|
||||||
|
if constraint.constraint_id == constraint_id:
|
||||||
|
constraint.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable_constraint(self, constraint_id: str) -> bool:
|
||||||
|
"""Disable a constraint by ID."""
|
||||||
|
for constraint in self.hard_constraints + self.soft_constraints:
|
||||||
|
if constraint.constraint_id == constraint_id:
|
||||||
|
constraint.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_violations(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if all violations can be cleared in context."""
|
||||||
|
violations = self.validate(context)
|
||||||
|
return len(violations) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_constraint_set(scenario: Dict[str, Any]) -> ConstraintSet:
|
||||||
|
"""Factory function to create a complete constraint set."""
|
||||||
|
hard = create_hard_constraints(scenario)
|
||||||
|
soft = create_soft_constraints(scenario)
|
||||||
|
return ConstraintSet(
|
||||||
|
hard_constraints=hard,
|
||||||
|
soft_constraints=soft,
|
||||||
|
scenario=scenario
|
||||||
|
)
|
||||||
64
constraints/base_constraint.py
Normal file
64
constraints/base_constraint.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Base constraint classes."""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Set, Dict, Optional, Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ConstraintType(Enum):
|
||||||
|
"""Types of constraints in the scheduling system."""
|
||||||
|
HARD = "hard" # Must be satisfied
|
||||||
|
SOFT = "soft" # Should be satisfied if possible
|
||||||
|
PREFERENCE = "preference" # Operator preferences
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Constraint:
|
||||||
|
"""Base constraint class with common functionality."""
|
||||||
|
constraint_id: str
|
||||||
|
constraint_type: ConstraintType
|
||||||
|
description: str
|
||||||
|
severity: int = 0 # Higher = more important
|
||||||
|
enabled: bool = True
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if constraint is satisfied in given context."""
|
||||||
|
# Base implementation - subclasses should override this
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_violation(self, context: Dict[str, Any]) -> Optional['ConstraintViolation']:
|
||||||
|
"""Get violation if constraint is not satisfied."""
|
||||||
|
if self.is_satisfied(context):
|
||||||
|
return None
|
||||||
|
return ConstraintViolation(
|
||||||
|
constraint_id=self.constraint_id,
|
||||||
|
constraint_type=self.constraint_type,
|
||||||
|
description=self.description,
|
||||||
|
severity=self.severity,
|
||||||
|
context=context
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConstraintViolation:
|
||||||
|
"""Represents a constraint violation."""
|
||||||
|
constraint_id: str
|
||||||
|
constraint_type: ConstraintType
|
||||||
|
description: str
|
||||||
|
severity: int
|
||||||
|
context: Dict[str, Any]
|
||||||
|
violation_message: str = field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.violation_message = f"{self.constraint_id}: {self.description}"
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert violation to dictionary."""
|
||||||
|
return {
|
||||||
|
'constraint_id': self.constraint_id,
|
||||||
|
'constraint_type': self.constraint_type.value,
|
||||||
|
'description': self.description,
|
||||||
|
'severity': self.severity,
|
||||||
|
'context': self.context,
|
||||||
|
'violation_message': self.violation_message
|
||||||
|
}
|
||||||
1
constraints/global_constraints.py
Normal file
1
constraints/global_constraints.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Global constraints."""
|
||||||
120
constraints/hard_constraints.py
Normal file
120
constraints/hard_constraints.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Hard constraints - must always be satisfied."""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from constraints.base_constraint import Constraint, ConstraintViolation, ConstraintType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SkillRequirementConstraint(Constraint):
|
||||||
|
"""Constraint ensuring operator has required skills for shift."""
|
||||||
|
operator_id: str
|
||||||
|
required_skills: List[str]
|
||||||
|
shift_id: str
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if operator has all required skills."""
|
||||||
|
operator_skills = context.get('operators', {}).get(self.operator_id, {}).get('skills', [])
|
||||||
|
return all(skill in operator_skills for skill in self.required_skills)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ShiftCoverageConstraint(Constraint):
|
||||||
|
"""Constraint ensuring shift is covered by at least one operator."""
|
||||||
|
shift_id: str
|
||||||
|
minimum_coverage: int = 1
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if shift has minimum coverage."""
|
||||||
|
assigned_operators = context.get('assignments', {}).get(self.shift_id, [])
|
||||||
|
return len(assigned_operators) >= self.minimum_coverage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MaxWorkTimeConstraint(Constraint):
|
||||||
|
"""Constraint limiting maximum working hours per day/week."""
|
||||||
|
operator_id: str
|
||||||
|
max_daily_hours: int = 8
|
||||||
|
max_weekly_hours: int = 40
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if operator hasn't exceeded max hours."""
|
||||||
|
operator_shifts = context.get('operators', {}).get(self.operator_id, {}).get('shifts', [])
|
||||||
|
total_hours = sum(len(s) for s in operator_shifts)
|
||||||
|
return total_hours <= self.max_daily_hours
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DepartmentAssignmentConstraint(Constraint):
|
||||||
|
"""Constraint ensuring operator is assigned to correct department."""
|
||||||
|
operator_id: str
|
||||||
|
allowed_departments: List[str]
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if operator is in allowed department."""
|
||||||
|
assigned_dept = context.get('assignments', {}).get(self.operator_id, {}).get('department')
|
||||||
|
return assigned_dept in self.allowed_departments
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MinCoverageConstraint(Constraint):
|
||||||
|
"""Constraint ensuring minimum operators per shift."""
|
||||||
|
shift_id: str
|
||||||
|
min_operators: int
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if minimum operators are assigned."""
|
||||||
|
assigned = context.get('assignments', {}).get(self.shift_id, [])
|
||||||
|
return len(assigned) >= self.min_operators
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConflictPreventionConstraint(Constraint):
|
||||||
|
"""Constraint preventing conflicting assignments."""
|
||||||
|
operator_id: str
|
||||||
|
conflicts: List[str]
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check for conflicts."""
|
||||||
|
current_conflicts = [c for c in self.conflicts if c in context.get('conflicts', {})]
|
||||||
|
return len(current_conflicts) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_hard_constraints(scenario: Dict[str, Any]) -> List[Constraint]:
|
||||||
|
"""Factory function to create all hard constraints from scenario."""
|
||||||
|
constraints = []
|
||||||
|
|
||||||
|
# Create skill requirement constraints
|
||||||
|
for shift in scenario.get('events', []):
|
||||||
|
for agent in scenario.get('agents', []):
|
||||||
|
if agent.get('type') == 'operator':
|
||||||
|
constraints.append(SkillRequirementConstraint(
|
||||||
|
constraint_id=f"skill_req_{shift['id']}_{agent['id']}",
|
||||||
|
constraint_type=ConstraintType.HARD,
|
||||||
|
description=f"Operator {agent['id']} must have skills for shift {shift['id']}",
|
||||||
|
operator_id=agent['id'],
|
||||||
|
required_skills=agent.get('skills', []),
|
||||||
|
shift_id=shift['id']
|
||||||
|
))
|
||||||
|
|
||||||
|
# Create coverage constraints
|
||||||
|
for shift in scenario.get('events', []):
|
||||||
|
constraints.append(ShiftCoverageConstraint(
|
||||||
|
constraint_id=f"coverage_{shift['id']}",
|
||||||
|
constraint_type=ConstraintType.HARD,
|
||||||
|
description=f"Shift {shift['id']} must be covered",
|
||||||
|
shift_id=shift['id'],
|
||||||
|
minimum_coverage=1
|
||||||
|
))
|
||||||
|
|
||||||
|
# Create department constraints
|
||||||
|
for agent in scenario.get('agents', []):
|
||||||
|
if agent.get('type') == 'operator':
|
||||||
|
constraints.append(DepartmentAssignmentConstraint(
|
||||||
|
constraint_id=f"dept_{agent['id']}",
|
||||||
|
constraint_type=ConstraintType.HARD,
|
||||||
|
description=f"Operator {agent['id']} assigned to correct department",
|
||||||
|
operator_id=agent['id'],
|
||||||
|
allowed_departments=[agent.get('department', '')]
|
||||||
|
))
|
||||||
|
|
||||||
|
return constraints
|
||||||
123
constraints/soft_constraints.py
Normal file
123
constraints/soft_constraints.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Soft constraints - should be satisfied when possible."""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from constraints.base_constraint import Constraint, ConstraintViolation, ConstraintType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OperatorPreferenceConstraint(Constraint):
|
||||||
|
"""Soft constraint for operator shift preferences."""
|
||||||
|
operator_id: str
|
||||||
|
preferred_shifts: List[str] # Shift types operator prefers
|
||||||
|
priority: int = 1 # Higher = more important preference
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if operator is assigned preferred shifts."""
|
||||||
|
assigned_shifts = context.get('operators', {}).get(self.operator_id, {}).get('shifts', [])
|
||||||
|
assigned_shift_types = [s.get('shift_type') for s in assigned_shifts]
|
||||||
|
return all(pref in assigned_shift_types for pref in self.preferred_shifts)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DayAvailabilityConstraint(Constraint):
|
||||||
|
"""Soft constraint for operator day availability."""
|
||||||
|
operator_id: str
|
||||||
|
preferred_days: List[str]
|
||||||
|
priority: int = 1
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if operator works on preferred days."""
|
||||||
|
assigned_shifts = context.get('operators', {}).get(self.operator_id, {}).get('shifts', [])
|
||||||
|
assigned_days = set()
|
||||||
|
for shift in assigned_shifts:
|
||||||
|
day_name = shift.get('date', {}).get('strftime', '%Y-%m-%d')
|
||||||
|
assigned_days.add(day_name)
|
||||||
|
return all(day in assigned_days for day in self.preferred_days)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WorkBalanceConstraint(Constraint):
|
||||||
|
"""Soft constraint for balanced workload distribution."""
|
||||||
|
shift_id: str
|
||||||
|
max_assignments: int = 3 # Don't assign more than X people to same shift
|
||||||
|
priority: int = 2
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if shift isn't overloaded."""
|
||||||
|
assigned = context.get('assignments', {}).get(self.shift_id, [])
|
||||||
|
return len(assigned) <= self.max_assignments
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ContinuityPreference(Constraint):
|
||||||
|
"""Soft constraint for consecutive shift preferences."""
|
||||||
|
operator_id: str
|
||||||
|
preferred_pattern: str # e.g., "morning,afternoon" for consecutive shifts
|
||||||
|
priority: int = 1
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if consecutive shifts match preference."""
|
||||||
|
assigned_shifts = context.get('operators', {}).get(self.operator_id, {}).get('shifts', [])
|
||||||
|
if len(assigned_shifts) < 2:
|
||||||
|
return True
|
||||||
|
# Check if shifts are consecutive and match pattern
|
||||||
|
return True # Implementation depends on shift ordering
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeniorityPriorityConstraint(Constraint):
|
||||||
|
"""Soft constraint to prioritize senior operators."""
|
||||||
|
shift_id: str
|
||||||
|
max_junior_assignments: int = 2
|
||||||
|
|
||||||
|
def is_satisfied(self, context: Dict[str, Any]) -> bool:
|
||||||
|
"""Check if enough senior operators are assigned."""
|
||||||
|
assigned = context.get('assignments', {}).get(self.shift_id, [])
|
||||||
|
junior_count = sum(1 for op in assigned if op.get('seniority', 0) < 3)
|
||||||
|
return junior_count <= self.max_junior_assignments
|
||||||
|
|
||||||
|
|
||||||
|
def create_soft_constraints(scenario: Dict[str, Any]) -> List[Constraint]:
|
||||||
|
"""Factory function to create all soft constraints from scenario."""
|
||||||
|
constraints = []
|
||||||
|
|
||||||
|
# Create preference constraints
|
||||||
|
for agent in scenario.get('agents', []):
|
||||||
|
if agent.get('type') == 'operator':
|
||||||
|
prefs = agent.get('preferences', {})
|
||||||
|
if prefs:
|
||||||
|
constraints.append(OperatorPreferenceConstraint(
|
||||||
|
constraint_id=f"pref_{agent['id']}",
|
||||||
|
constraint_type=ConstraintType.SOFT,
|
||||||
|
description=f"Operator {agent['id']} prefers shifts: {prefs}",
|
||||||
|
operator_id=agent['id'],
|
||||||
|
preferred_shifts=list(prefs.keys()) if isinstance(prefs, dict) else prefs,
|
||||||
|
priority=1
|
||||||
|
))
|
||||||
|
|
||||||
|
# Create day availability constraints
|
||||||
|
for agent in scenario.get('agents', []):
|
||||||
|
if agent.get('type') == 'operator':
|
||||||
|
preferred_days = agent.get('preferred_days', [])
|
||||||
|
if preferred_days:
|
||||||
|
constraints.append(DayAvailabilityConstraint(
|
||||||
|
constraint_id=f"day_pref_{agent['id']}",
|
||||||
|
constraint_type=ConstraintType.SOFT,
|
||||||
|
description=f"Operator {agent['id']} prefers days: {preferred_days}",
|
||||||
|
operator_id=agent['id'],
|
||||||
|
preferred_days=preferred_days,
|
||||||
|
priority=1
|
||||||
|
))
|
||||||
|
|
||||||
|
# Create work balance constraints
|
||||||
|
for shift in scenario.get('events', []):
|
||||||
|
constraints.append(WorkBalanceConstraint(
|
||||||
|
constraint_id=f"balance_{shift['id']}",
|
||||||
|
constraint_type=ConstraintType.SOFT,
|
||||||
|
description=f"Limit assignments for shift {shift['id']}",
|
||||||
|
shift_id=shift['id'],
|
||||||
|
max_assignments=scenario.get('constraints', {}).get('max_per_shift', 3),
|
||||||
|
priority=2
|
||||||
|
))
|
||||||
|
|
||||||
|
return constraints
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=production
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
# Optional: PostgreSQL
|
||||||
|
db:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: scheduler
|
||||||
|
POSTGRES_PASSWORD: scheduler
|
||||||
|
POSTGRES_DB: scheduler_db
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
1
exporters/__init__.py
Normal file
1
exporters/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Exporters package."""
|
||||||
1
exporters/base_exporter.py
Normal file
1
exporters/base_exporter.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Base exporter."""
|
||||||
1
exporters/csv_exporter.py
Normal file
1
exporters/csv_exporter.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""CSV exporter."""
|
||||||
1
exporters/excel_exporter.py
Normal file
1
exporters/excel_exporter.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Excel exporter."""
|
||||||
1
exporters/html_exporter.py
Normal file
1
exporters/html_exporter.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""HTML exporter."""
|
||||||
1
fixtures/__init__.py
Normal file
1
fixtures/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Fixtures package."""
|
||||||
154
main.py
154
main.py
@@ -1,81 +1,101 @@
|
|||||||
"""
|
from ortools.sat.python import cp_model
|
||||||
main.py - Основной скрипт для создания графика смен
|
|
||||||
Использует модуль models.py для создания и решения модели
|
|
||||||
"""
|
|
||||||
from models import create_model, solve_model, get_solution
|
|
||||||
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
|
||||||
import csv
|
import csv
|
||||||
|
|
||||||
def generate_csv_report(schedule: dict, filename: str = 'shift_schedule.csv'):
|
# Определяем даты апреля 2026 года
|
||||||
"""
|
days = [
|
||||||
Генерирует CSV-отчет на основе расписания.
|
"2026-04-{:02d}".format(i) for i in range(1, 31)
|
||||||
|
]
|
||||||
|
|
||||||
Аргументы:
|
# Смены
|
||||||
schedule: словарь расписания из get_solution()
|
shifts = [
|
||||||
filename: имя файла для вывода CSV
|
(6, 14.5), # с 06:00 до 14:30
|
||||||
|
(8, 16.5), # с 08:00 до 16:30
|
||||||
|
(11.5, 20), # с 11:30 до 20:00
|
||||||
|
(12.5, 21) # с 12:30 до 21:00
|
||||||
|
]
|
||||||
|
|
||||||
Возвращает:
|
# Операторы и их пожелания
|
||||||
True если файл создан успешно
|
operators = [
|
||||||
"""
|
"Иванов",
|
||||||
with open(filename, 'w', newline='', encoding='utf-8') as file:
|
"Алексеев",
|
||||||
|
"Сидоров",
|
||||||
|
"Козобородов",
|
||||||
|
"Трудяшкина",
|
||||||
|
"Петрова"
|
||||||
|
]
|
||||||
|
|
||||||
|
operator_preferences = {
|
||||||
|
"Иванов": ["2026-04-10", "2026-04-13"],
|
||||||
|
"Алексеев": [(i, 0) for i in days if (int(i[-2:]) - 1) % 7 == 0], # Понедельники
|
||||||
|
"Сидоров": ["2026-04-25", "2026-04-26"],
|
||||||
|
"Козобородов": [(i, 2) for i in days], # Предпочитает смену 11:30–20:00
|
||||||
|
"Трудяшкина": ["2026-04-04"],
|
||||||
|
"Петрова": ["2026-04-08", "2026-04-29"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Создаем модель
|
||||||
|
model = cp_model.CpModel()
|
||||||
|
|
||||||
|
# Переменные: определяем, работает ли оператор на смене в день
|
||||||
|
works = {}
|
||||||
|
for day in days:
|
||||||
|
for shift_idx in range(len(shifts)):
|
||||||
|
for op in operators:
|
||||||
|
works[(day, shift_idx, op)] = model.NewBoolVar(f"{op}_{day}_shift_{shift_idx}")
|
||||||
|
|
||||||
|
# # Пожелания операторов
|
||||||
|
for op, prefs in operator_preferences.items():
|
||||||
|
if isinstance(prefs, list):
|
||||||
|
for pref in prefs:
|
||||||
|
if isinstance(pref, tuple):
|
||||||
|
day, shift = pref
|
||||||
|
model.AddHint(works[day, shift, op], 1)
|
||||||
|
else:
|
||||||
|
day = pref
|
||||||
|
model.Add(sum(works[day, shift_idx, op] for shift_idx in range(len(shifts))) == 0)
|
||||||
|
|
||||||
|
# Условие 1: На каждую смену должен быть назначен оператор
|
||||||
|
for day in days:
|
||||||
|
for shift_idx in range(len(shifts)):
|
||||||
|
model.Add(sum(works[day, shift_idx, op] for op in operators) == 1)
|
||||||
|
|
||||||
|
# Условие 2: У каждого оператора должно быть 8 выходных
|
||||||
|
for op in operators:
|
||||||
|
model.Add(sum(works[day, shift_idx, op] for day in days for shift_idx in range(len(shifts))) == 20 )
|
||||||
|
|
||||||
|
# Условие 3: Оператор не должен работать больше 5 дней подряд
|
||||||
|
for op in operators:
|
||||||
|
for day_idx in range(len(days) - 4):
|
||||||
|
model.Add(sum(works[days[day_idx + i], shift_idx, op] for i in range(5) for shift_idx in range(len(shifts))) <= 4)
|
||||||
|
|
||||||
|
# Условие 4: В день оператор может работать только одну смену
|
||||||
|
for day in days:
|
||||||
|
for op in operators:
|
||||||
|
model.Add(sum(works[day, shift_idx, op] for shift_idx in range(len(shifts))) <= 1)
|
||||||
|
|
||||||
|
# Решаем модель
|
||||||
|
solver = cp_model.CpSolver()
|
||||||
|
status = solver.Solve(model)
|
||||||
|
|
||||||
|
if status != cp_model.OPTIMAL and status != cp_model.FEASIBLE:
|
||||||
|
print("Невозможно составить график")
|
||||||
|
else:
|
||||||
|
# Создаем CSV файл с результатами
|
||||||
|
with open('shift_schedule.csv', 'w', newline='', encoding='utf-8') as file:
|
||||||
writer = csv.writer(file)
|
writer = csv.writer(file)
|
||||||
# Заголовок
|
writer.writerow(['Day'] + [f'{i+1} shift' for i in range(len(shifts))])
|
||||||
writer.writerow(['День'] + [f'Смена {i+1}' for i in range(len(SHIFTS))])
|
|
||||||
|
|
||||||
for day, day_shifts in schedule.items():
|
for day in days:
|
||||||
row = [day]
|
row = [day]
|
||||||
for shift_idx in range(len(SHIFTS)):
|
for shift_idx in range(len(shifts)):
|
||||||
op = None
|
op = None
|
||||||
for op_name in OPERATORS:
|
for op_name in operators:
|
||||||
if schedule[day][shift_idx].get(op_name, False):
|
if solver.Value(works[day, shift_idx, op_name]) == 1:
|
||||||
op = op_name
|
op = op_name
|
||||||
break
|
break
|
||||||
row.append(op)
|
row.append(op)
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
print(f"График успешно составлен и сохранен в '{filename}'")
|
print("График успешно составлен и сохранен в 'shift_schedule.csv'")
|
||||||
return True
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""
|
|
||||||
Главная функция: создает и решает модель, затем генерирует CSV-отчет.
|
|
||||||
"""
|
|
||||||
print("=" * 60)
|
|
||||||
print("Создание графика смен")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Шаг 1: Создаем модель
|
|
||||||
print("\n[1] Создание модели. ..")
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
print(f" Модель создана с {len(variables)} переменными")
|
|
||||||
print(f" Операторы: {', '.join(operators_set)}")
|
|
||||||
|
|
||||||
# Шаг 2: Решаем модель
|
|
||||||
print("\n[2] Решение модели. ..")
|
|
||||||
success = solve_model()
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(" ✅ Модель решена успешно!")
|
|
||||||
|
|
||||||
# Шаг 3: Получаем решение
|
|
||||||
print("\n[3] Получение решения. ..")
|
|
||||||
schedule = get_solution()
|
|
||||||
print(f" Решение получено для {len(schedule)} дней")
|
|
||||||
|
|
||||||
# Шаг 4: Генерируем CSV-отчет
|
|
||||||
print("\n[4] Генерация CSV-отчета. ..")
|
|
||||||
generate_csv_report(schedule, 'shift_schedule.csv')
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(" ❌ Не удалось составить график!")
|
|
||||||
print(" Проверьте ограничения и предпочтения операторов.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Готово!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
293
models.py
293
models.py
@@ -1,293 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import List, Dict, Set, Union, Optional
|
|
||||||
from ortools.sat.python import cp_model
|
|
||||||
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
|
||||||
|
|
||||||
class PreferenceType(Enum):
|
|
||||||
"""Типы предпочтений"""
|
|
||||||
WORK_ON_DATE = "work_on_date"
|
|
||||||
WORK_ON_DAY_OF_WEEK = "work_on_day_of_week"
|
|
||||||
WORK_ONLY_SHIFT = "work_only_shift"
|
|
||||||
WORK_ONLY_DATE = "work_only_date"
|
|
||||||
OFF_ON_DATE = "off_on_date"
|
|
||||||
|
|
||||||
class DayOfWeek(Enum):
|
|
||||||
"""Дни недели"""
|
|
||||||
MONDAY = 0
|
|
||||||
TUESDAY = 1
|
|
||||||
WEDNESDAY = 2
|
|
||||||
THURSDAY = 3
|
|
||||||
FRIDAY = 4
|
|
||||||
SATURDAY = 5
|
|
||||||
SUNDAY = 6
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OperatorPreferences:
|
|
||||||
"""
|
|
||||||
Класс для управления предпочтениями оператора.
|
|
||||||
Использует паттерн Builder для удобного создания.
|
|
||||||
"""
|
|
||||||
operator_name: str
|
|
||||||
work_on_dates: List[int] = field(default_factory=list) # Дни месяца (1-31)
|
|
||||||
work_on_day_of_week: List[DayOfWeek] = field(default_factory=list)
|
|
||||||
work_only_shift: Optional[int] = None # Индекс смены (0-3)
|
|
||||||
off_on_dates: List[int] = field(default_factory=list) # Дни, когда оператор НЕ работает
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.work_only_shift is not None and not (0 <= self.work_only_shift < len(SHIFTS)):
|
|
||||||
raise ValueError(f"Индекс смены {self.work_only_shift} вне диапазона 0-{len(SHIFTS)-1}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, operator_name: str) -> 'PreferencesBuilder':
|
|
||||||
"""Статический метод для начала построения предпочтений"""
|
|
||||||
return PreferencesBuilder(operator_name)
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Union[int, str, List[int]]]:
|
|
||||||
"""
|
|
||||||
Конвертирует объект предпочтений в формат, пригодный для config.py.
|
|
||||||
Возвращает словарь с ключами, понятными для парсера в create_model.
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
if self.work_only_shift is not None:
|
|
||||||
result['shift'] = self.work_only_shift
|
|
||||||
|
|
||||||
if self.work_on_day_of_week:
|
|
||||||
day_names = [d.name.lower() for d in self.work_on_day_of_week]
|
|
||||||
# Если только один день недели, можно хранить как строку, иначе как список
|
|
||||||
if len(day_names) == 1:
|
|
||||||
result['day_of_week'] = day_names[0]
|
|
||||||
else:
|
|
||||||
result['day_of_week'] = day_names
|
|
||||||
|
|
||||||
if self.work_on_dates:
|
|
||||||
result['dates'] = self.work_on_dates
|
|
||||||
|
|
||||||
if self.off_on_dates:
|
|
||||||
result['off_dates'] = self.off_on_dates
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Метод для отладки"""
|
|
||||||
parts = []
|
|
||||||
if self.work_only_shift is not None:
|
|
||||||
parts.append(f"Shift {self.work_only_shift}")
|
|
||||||
if self.work_on_day_of_week:
|
|
||||||
parts.append(f"Days: {', '.join([d.name for d in self.work_on_day_of_week])}")
|
|
||||||
if self.work_on_dates:
|
|
||||||
parts.append(f"Dates: {self.work_on_dates}")
|
|
||||||
if self.off_on_dates:
|
|
||||||
parts.append(f"Off: {self.off_on_dates}")
|
|
||||||
|
|
||||||
return f"OperatorPreferences({self.operator_name}: {', '.join(parts)})"
|
|
||||||
|
|
||||||
class PreferencesBuilder:
|
|
||||||
"""Builder для создания предпочтений оператора"""
|
|
||||||
|
|
||||||
def __init__(self, operator_name: str):
|
|
||||||
self.operator_name = operator_name
|
|
||||||
self.prefs = OperatorPreferences(operator_name)
|
|
||||||
|
|
||||||
def work_on_date(self, day: int) -> 'PreferencesBuilder':
|
|
||||||
"""Добавить день месяца для работы"""
|
|
||||||
if not 1 <= day <= 31:
|
|
||||||
raise ValueError(f"День {day} вне диапазона 1-31")
|
|
||||||
self.prefs.work_on_dates.append(day)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def work_on_dates(self, days: List[int]) -> 'PreferencesBuilder':
|
|
||||||
"""Добавить список дней месяца для работы"""
|
|
||||||
for day in days:
|
|
||||||
self.work_on_date(day)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def work_on_day_of_week(self, day: DayOfWeek) -> 'PreferencesBuilder':
|
|
||||||
"""Добавить день недели для работы"""
|
|
||||||
if day not in self.prefs.work_on_day_of_week:
|
|
||||||
self.prefs.work_on_day_of_week.append(day)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def work_on_days_of_week(self, days: List[DayOfWeek]) -> 'PreferencesBuilder':
|
|
||||||
"""Добавить список дней недели для работы"""
|
|
||||||
for day in days:
|
|
||||||
self.work_on_day_of_week(day)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def work_only_shift(self, shift_idx: int) -> 'PreferencesBuilder':
|
|
||||||
"""Установить предпочтительную смену (работает только в этой смене)"""
|
|
||||||
self.prefs.work_only_shift = shift_idx
|
|
||||||
return self
|
|
||||||
|
|
||||||
def off_on_date(self, day: int) -> 'PreferencesBuilder':
|
|
||||||
"""Установить день, когда оператор НЕ работает"""
|
|
||||||
if not 1 <= day <= 31:
|
|
||||||
raise ValueError(f"День {day} вне диапазона 1-31")
|
|
||||||
self.prefs.off_on_dates.append(day)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def off_on_dates(self, days: List[int]) -> 'PreferencesBuilder':
|
|
||||||
"""Установить дни, когда оператор НЕ работает"""
|
|
||||||
for day in days:
|
|
||||||
self.off_on_date(day)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def build(self) -> OperatorPreferences:
|
|
||||||
"""Завершить построение и вернуть объект предпочтений"""
|
|
||||||
return self.prefs
|
|
||||||
|
|
||||||
def get_days() -> List[str]:
|
|
||||||
"""Генерирует список дат месяца"""
|
|
||||||
return [f"{YEAR}-{MONTH:02d}-{i:02d}" for i in range(1, 31)]
|
|
||||||
|
|
||||||
def create_model() -> Tuple[cp_model.CpModel, Dict, Set]:
|
|
||||||
"""
|
|
||||||
Создаёт и настраивает CP-модель.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
model: CP-модель
|
|
||||||
variables: словарь переменных работы
|
|
||||||
operators_set: множество операторов для удобства
|
|
||||||
"""
|
|
||||||
model = cp_model.CpModel()
|
|
||||||
variables = {}
|
|
||||||
days = get_days()
|
|
||||||
|
|
||||||
# Определяем переменные: works[(day, shift_idx, op)]
|
|
||||||
for day in days:
|
|
||||||
for shift_idx in range(len(SHIFTS)):
|
|
||||||
for op in OPERATORS:
|
|
||||||
var_name = f"{op}_{day}_shift_{shift_idx}"
|
|
||||||
variables[(day, shift_idx, op)] = model.NewBoolVar(var_name)
|
|
||||||
|
|
||||||
# Определяем множество операторов
|
|
||||||
operators_set = set(OPERATORS)
|
|
||||||
|
|
||||||
# 1. Учёт предпочтений операторов
|
|
||||||
# Ожидаем, что OPERATOR_PREFERENCES теперь содержит объекты OperatorPreferences или их словари
|
|
||||||
for op, prefs_data in OPERATOR_PREFERENCES.items():
|
|
||||||
# Если это словарь (из config.py), преобразуем в объект
|
|
||||||
if isinstance(prefs_data, dict):
|
|
||||||
prefs = OperatorPreferences(op)
|
|
||||||
if 'shift' in prefs_data:
|
|
||||||
prefs.work_only_shift = prefs_data['shift']
|
|
||||||
if 'day_of_week' in prefs_data:
|
|
||||||
dow = prefs_data['day_of_week']
|
|
||||||
if isinstance(dow, str):
|
|
||||||
try:
|
|
||||||
prefs.work_on_day_of_week.append(DayOfWeek[dow.upper()])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
elif isinstance(dow, list):
|
|
||||||
for d in dow:
|
|
||||||
try:
|
|
||||||
prefs.work_on_day_of_week.append(DayOfWeek[d.upper()])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if 'dates' in prefs_data:
|
|
||||||
prefs.work_on_dates = prefs_data['dates']
|
|
||||||
if 'off_dates' in prefs_data:
|
|
||||||
prefs.off_on_dates = prefs_data['off_dates']
|
|
||||||
else:
|
|
||||||
prefs = prefs_data
|
|
||||||
|
|
||||||
# Применяем предпочтения
|
|
||||||
if prefs.work_only_shift is not None:
|
|
||||||
# Оператор предпочитает конкретную смену
|
|
||||||
shift_idx = prefs.work_only_shift
|
|
||||||
for day in days:
|
|
||||||
model.AddHint(variables[(day, shift_idx, op)], 1)
|
|
||||||
|
|
||||||
if prefs.work_on_day_of_week:
|
|
||||||
# Оператор работает только в определенные дни недели
|
|
||||||
for day in days:
|
|
||||||
date_obj = datetime(int(day.split('-')[0]), int(day.split('-')[1]), int(day.split('-')[2]))
|
|
||||||
if date_obj.weekday() in [d.value for d in prefs.work_on_day_of_week]:
|
|
||||||
# Если есть preference_shift, то только в эту смену
|
|
||||||
if prefs.work_only_shift is not None:
|
|
||||||
model.AddHint(variables[(day, prefs.work_only_shift, op)], 1)
|
|
||||||
else:
|
|
||||||
# Иначе в любую смену (но это уже обрабатывается другими ограничениями)
|
|
||||||
# Для hint можно добавить в первую смену как ориентир
|
|
||||||
model.AddHint(variables[(day, 0, op)], 1)
|
|
||||||
|
|
||||||
if prefs.work_on_dates:
|
|
||||||
# Оператор предпочитает конкретные дни
|
|
||||||
for pref_day_num in prefs.work_on_dates:
|
|
||||||
target_day = f"{YEAR}-{MONTH:02d}-{pref_day_num:02d}"
|
|
||||||
if target_day in days:
|
|
||||||
if prefs.work_only_shift is not None:
|
|
||||||
model.AddHint(variables[(target_day, prefs.work_only_shift, op)], 1)
|
|
||||||
else:
|
|
||||||
model.AddHint(variables[(target_day, 0, op)], 1)
|
|
||||||
|
|
||||||
if prefs.off_on_dates:
|
|
||||||
# Оператор НЕ работает в эти дни
|
|
||||||
for off_day_num in prefs.off_on_dates:
|
|
||||||
target_day = f"{YEAR}-{MONTH:02d}-{off_day_num:02d}"
|
|
||||||
if target_day in days:
|
|
||||||
# Запрещаем работу в этот день
|
|
||||||
for shift_idx in range(len(SHIFTS)):
|
|
||||||
model.Add(variables[(target_day, shift_idx, op)] == 0)
|
|
||||||
|
|
||||||
# Условие 1: На каждую смену должен быть назначен оператор
|
|
||||||
for day in days:
|
|
||||||
for shift_idx in range(len(SHIFTS)):
|
|
||||||
model.Add(sum(variables[(day, shift_idx, op)] for op in OPERATORS) == 1)
|
|
||||||
|
|
||||||
# Условие 2: У каждого оператора должно быть 8 выходных (20 рабочих дней)
|
|
||||||
for op in OPERATORS:
|
|
||||||
model.Add(sum(variables[(day, shift_idx, op)] for day in days for shift_idx in range(len(SHIFTS))) == 20)
|
|
||||||
|
|
||||||
# Условие 3: Оператор не должен работать больше 5 дней подряд
|
|
||||||
for op in OPERATORS:
|
|
||||||
for day_idx in range(len(days) - 4):
|
|
||||||
model.Add(sum(variables[(days[day_idx + i], shift_idx, op)]
|
|
||||||
for i in range(5)
|
|
||||||
for shift_idx in range(len(SHIFTS))) <= 4)
|
|
||||||
|
|
||||||
# Условие 4: В день оператор может работать только одну смену
|
|
||||||
for day in days:
|
|
||||||
for op in OPERATORS:
|
|
||||||
model.Add(sum(variables[(day, shift_idx, op)] for shift_idx in range(len(SHIFTS))) <= 1)
|
|
||||||
|
|
||||||
return model, variables, operators_set
|
|
||||||
|
|
||||||
def solve_model() -> bool:
|
|
||||||
"""
|
|
||||||
Решает модель.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
True - если решение найдено
|
|
||||||
False - если не удалось составить график
|
|
||||||
"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
|
|
||||||
solver = cp_model.CpSolver()
|
|
||||||
status = solver.Solve(model)
|
|
||||||
|
|
||||||
return status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
|
|
||||||
|
|
||||||
def get_solution() -> Dict:
|
|
||||||
"""
|
|
||||||
Получает решение модели.
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
Словарь с расписанием: {день: {смена: {оператор: True/False}}}
|
|
||||||
"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
solver = cp_model.CpSolver()
|
|
||||||
solver.Solve(model)
|
|
||||||
|
|
||||||
schedule = {}
|
|
||||||
for day in get_days():
|
|
||||||
schedule[day] = {
|
|
||||||
shift_idx: {op: solver.Value(variables[(day, shift_idx, op)])
|
|
||||||
for op in OPERATORS}
|
|
||||||
for shift_idx in range(len(SHIFTS))
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule
|
|
||||||
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Core dependencies
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
Flask>=3.0.0
|
||||||
|
Flask-SQLAlchemy>=3.1.1
|
||||||
|
WTForms>=3.1.2
|
||||||
|
Jinja2>=3.1.3
|
||||||
|
Pillow>=10.2.0
|
||||||
|
openpyxl>=3.1.2
|
||||||
|
pandas>=2.1.4
|
||||||
|
pytest>=7.4.3
|
||||||
|
pytest-cov>=4.1.0
|
||||||
|
|
||||||
|
# Solver
|
||||||
|
ortools>=9.7.2703
|
||||||
|
|
||||||
|
# Dev tools
|
||||||
|
pytest-xdist>=3.3.1
|
||||||
|
black>=23.12.1
|
||||||
|
isort>=5.12.0
|
||||||
|
mypy>=1.7.1
|
||||||
|
flake8>=6.1.0
|
||||||
1
scripts/__init__.py
Normal file
1
scripts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Scripts package."""
|
||||||
31
shift_schedule.csv
Normal file
31
shift_schedule.csv
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
Day,1 shift,2 shift,3 shift,4 shift
|
||||||
|
2026-04-01,Иванов,Трудяшкина,Козобородов,Сидоров
|
||||||
|
2026-04-02,Иванов,Трудяшкина,Петрова,Козобородов
|
||||||
|
2026-04-03,Петрова,Сидоров,Алексеев,Козобородов
|
||||||
|
2026-04-04,Петрова,Иванов,Алексеев,Сидоров
|
||||||
|
2026-04-05,Сидоров,Иванов,Козобородов,Трудяшкина
|
||||||
|
2026-04-06,Трудяшкина,Алексеев,Петрова,Иванов
|
||||||
|
2026-04-07,Сидоров,Алексеев,Петрова,Иванов
|
||||||
|
2026-04-08,Алексеев,Сидоров,Козобородов,Трудяшкина
|
||||||
|
2026-04-09,Иванов,Козобородов,Петрова,Трудяшкина
|
||||||
|
2026-04-10,Алексеев,Трудяшкина,Сидоров,Петрова
|
||||||
|
2026-04-11,Алексеев,Трудяшкина,Иванов,Козобородов
|
||||||
|
2026-04-12,Петрова,Сидоров,Иванов,Алексеев
|
||||||
|
2026-04-13,Трудяшкина,Петрова,Козобородов,Сидоров
|
||||||
|
2026-04-14,Алексеев,Иванов,Козобородов,Сидоров
|
||||||
|
2026-04-15,Алексеев,Козобородов,Петрова,Иванов
|
||||||
|
2026-04-16,Сидоров,Иванов,Трудяшкина,Петрова
|
||||||
|
2026-04-17,Иванов,Сидоров,Козобородов,Алексеев
|
||||||
|
2026-04-18,Петрова,Сидоров,Алексеев,Трудяшкина
|
||||||
|
2026-04-19,Иванов,Петрова,Козобородов,Трудяшкина
|
||||||
|
2026-04-20,Трудяшкина,Сидоров,Алексеев,Козобородов
|
||||||
|
2026-04-21,Алексеев,Петрова,Иванов,Трудяшкина
|
||||||
|
2026-04-22,Петрова,Иванов,Сидоров,Козобородов
|
||||||
|
2026-04-23,Иванов,Трудяшкина,Козобородов,Сидоров
|
||||||
|
2026-04-24,Сидоров,Трудяшкина,Козобородов,Алексеев
|
||||||
|
2026-04-25,Иванов,Алексеев,Козобородов,Петрова
|
||||||
|
2026-04-26,Петрова,Трудяшкина,Иванов,Алексеев
|
||||||
|
2026-04-27,Петрова,Трудяшкина,Козобородов,Сидоров
|
||||||
|
2026-04-28,Алексеев,Сидоров,Трудяшкина,Петрова
|
||||||
|
2026-04-29,Алексеев,Иванов,Козобородов,Трудяшкина
|
||||||
|
2026-04-30,Петрова,Алексеев,Козобородов,Сидоров
|
||||||
|
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core application package."""
|
||||||
9
src/config.py
Normal file
9
src/config.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Application configuration."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///scheduler.db")
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key")
|
||||||
|
|
||||||
|
UI_PORT = int(os.getenv("UI_PORT", "5000"))
|
||||||
1
src/core/__init__.py
Normal file
1
src/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core engine package."""
|
||||||
251
src/core/engine.py
Normal file
251
src/core/engine.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""Main scheduler engine that orchestrates the scheduling process.
|
||||||
|
|
||||||
|
This module coordinates the validation and solving phases to produce
|
||||||
|
a complete scheduling solution.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> from core.engine import SchedulerEngine
|
||||||
|
>>> from core.validator import ResultValidator
|
||||||
|
>>> from core.solver import CpSatSolver
|
||||||
|
>>> from src.models import create_scenario, generate_schedule
|
||||||
|
>>>
|
||||||
|
>>> engine = SchedulerEngine()
|
||||||
|
>>> engine.set_scenario(create_scenario())
|
||||||
|
>>> engine.set_validator(ResultValidator())
|
||||||
|
>>> engine.set_solver(CpSatSolver())
|
||||||
|
>>> schedule = engine.solve()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from src.core.validator import ResultValidator
|
||||||
|
from src.core.solver import CpSatSolver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SchedulingResult:
|
||||||
|
"""Result of scheduling process."""
|
||||||
|
schedule: Dict[str, Any]
|
||||||
|
validation_passed: bool
|
||||||
|
message: str
|
||||||
|
execution_time: float
|
||||||
|
statistics: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
status = "PASSED" if self.validation_passed else "FAILED"
|
||||||
|
return f"SchedulingResult(status={status}, message={self.message!r}, " \
|
||||||
|
f"execution_time={self.execution_time:.2f}s)"
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerEngine:
|
||||||
|
"""Main scheduler engine that coordinates scheduling operations.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
scenario: Current scheduling scenario
|
||||||
|
validator: Result validator
|
||||||
|
solver: Constraint solver
|
||||||
|
_statistics: Statistics collection
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the scheduler engine."""
|
||||||
|
self._scenario: Optional[Dict[str, Any]] = None
|
||||||
|
self._validator: Optional[ResultValidator] = None
|
||||||
|
self._solver: Optional[CpSatSolver] = None
|
||||||
|
self._statistics: Dict[str, Any] = {
|
||||||
|
'validation_attempts': 0,
|
||||||
|
'validation_passed': 0,
|
||||||
|
'solving_attempts': 0,
|
||||||
|
'solving_success': 0,
|
||||||
|
'total_runtime': 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_scenario(self, scenario: Dict[str, Any]) -> None:
|
||||||
|
"""Set the scheduling scenario.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scenario: Complete scenario data including:
|
||||||
|
- agents: List of agent definitions
|
||||||
|
- events: List of event definitions
|
||||||
|
- preferences: Agent preference mappings
|
||||||
|
- constraints: List of constraints
|
||||||
|
- objectives: List of optimization objectives
|
||||||
|
- time_window: Start and end times
|
||||||
|
- max_iterations: Maximum number of iterations
|
||||||
|
"""
|
||||||
|
if scenario is None:
|
||||||
|
raise ValueError("Scenario cannot be None")
|
||||||
|
|
||||||
|
# Validate scenario structure
|
||||||
|
required_keys = ['agents', 'events', 'preferences', 'constraints',
|
||||||
|
'objectives', 'time_window', 'max_iterations']
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in scenario:
|
||||||
|
raise ValueError(f"Missing required key in scenario: {key}")
|
||||||
|
|
||||||
|
self._scenario = scenario
|
||||||
|
logger.info(f"Scenario set with {len(scenario['agents'])} agents and "
|
||||||
|
f"{len(scenario['events'])} events")
|
||||||
|
|
||||||
|
def set_validator(self, validator: ResultValidator) -> None:
|
||||||
|
"""Set the result validator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
validator: Validator instance for result validation
|
||||||
|
"""
|
||||||
|
self._validator = validator
|
||||||
|
logger.info("Validator set")
|
||||||
|
|
||||||
|
def set_solver(self, solver: CpSatSolver) -> None:
|
||||||
|
"""Set the constraint solver.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
solver: Solver instance (e.g., CpSatSolver)
|
||||||
|
"""
|
||||||
|
self._solver = solver
|
||||||
|
logger.info("Solver set")
|
||||||
|
|
||||||
|
def validate_scenario(self) -> bool:
|
||||||
|
"""Validate the scheduling scenario.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if scenario is valid, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If scenario is invalid or not set
|
||||||
|
"""
|
||||||
|
if self._scenario is None:
|
||||||
|
raise ValueError("No scenario set")
|
||||||
|
|
||||||
|
if self._validator is None:
|
||||||
|
raise ValueError("No validator set")
|
||||||
|
|
||||||
|
self._statistics['validation_attempts'] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
validation_result = self._validator.validate(self._scenario)
|
||||||
|
self._scenario.update(validation_result)
|
||||||
|
self._statistics['validation_passed'] += 1
|
||||||
|
logger.info(f"Scenario validation {'PASSED' if validation_result['valid'] else 'FAILED'}")
|
||||||
|
return validation_result['valid']
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Validation failed: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def solve(self) -> SchedulingResult:
|
||||||
|
"""Solve the scheduling problem.
|
||||||
|
|
||||||
|
This method performs the following steps:
|
||||||
|
1. Validate the scenario
|
||||||
|
2. Solve using the constraint solver
|
||||||
|
3. Validate the solution
|
||||||
|
4. Collect statistics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SchedulingResult with the solution and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If scenario, validator, or solver not set
|
||||||
|
"""
|
||||||
|
# Record start time
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
# Step 1: Validate scenario
|
||||||
|
logger.info("Starting scenario validation...")
|
||||||
|
if not self.validate_scenario():
|
||||||
|
execution_time = (datetime.now() - start_time).total_seconds()
|
||||||
|
return SchedulingResult(
|
||||||
|
schedule={},
|
||||||
|
validation_passed=False,
|
||||||
|
message="Scenario validation failed",
|
||||||
|
execution_time=execution_time,
|
||||||
|
statistics=self._statistics.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Solve the problem
|
||||||
|
logger.info("Starting constraint solving...")
|
||||||
|
if self._solver is None:
|
||||||
|
raise ValueError("No solver set")
|
||||||
|
|
||||||
|
self._statistics['solving_attempts'] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
solution = self._solver.solve(self._scenario)
|
||||||
|
self._statistics['solving_success'] += 1
|
||||||
|
logger.info(f"Solution found with {len(solution.get('assignments', []))} assignments")
|
||||||
|
except Exception as e:
|
||||||
|
execution_time = (datetime.now() - start_time).total_seconds()
|
||||||
|
logger.error(f"Solving failed: {e}", exc_info=True)
|
||||||
|
return SchedulingResult(
|
||||||
|
schedule={},
|
||||||
|
validation_passed=False,
|
||||||
|
message=f"Solving failed: {str(e)}",
|
||||||
|
execution_time=execution_time,
|
||||||
|
statistics=self._statistics.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Validate the solution
|
||||||
|
logger.info("Validating solution...")
|
||||||
|
try:
|
||||||
|
solution_result = self._validator.validate_solution(solution, self._scenario)
|
||||||
|
self._scenario.update(solution_result)
|
||||||
|
|
||||||
|
if not solution_result['valid']:
|
||||||
|
execution_time = (datetime.now() - start_time).total_seconds()
|
||||||
|
return SchedulingResult(
|
||||||
|
schedule={},
|
||||||
|
validation_passed=False,
|
||||||
|
message=f"Solution validation failed: {solution_result.get('errors', [])}",
|
||||||
|
execution_time=execution_time,
|
||||||
|
statistics=self._statistics.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Collect statistics
|
||||||
|
self._statistics['total_runtime'] += (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
execution_time = self._statistics['total_runtime']
|
||||||
|
return SchedulingResult(
|
||||||
|
schedule=solution,
|
||||||
|
validation_passed=True,
|
||||||
|
message="Scheduling completed successfully",
|
||||||
|
execution_time=execution_time,
|
||||||
|
statistics=self._statistics.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
execution_time = (datetime.now() - start_time).total_seconds()
|
||||||
|
logger.error(f"Solution validation failed: {e}", exc_info=True)
|
||||||
|
return SchedulingResult(
|
||||||
|
schedule={},
|
||||||
|
validation_passed=False,
|
||||||
|
message=f"Solution validation failed: {str(e)}",
|
||||||
|
execution_time=execution_time,
|
||||||
|
statistics=self._statistics.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset the engine state."""
|
||||||
|
self._scenario = None
|
||||||
|
self._validator = None
|
||||||
|
self._solver = None
|
||||||
|
self._statistics = {
|
||||||
|
'validation_attempts': 0,
|
||||||
|
'validation_passed': 0,
|
||||||
|
'solving_attempts': 0,
|
||||||
|
'solving_success': 0,
|
||||||
|
'total_runtime': 0.0,
|
||||||
|
}
|
||||||
|
logger.info("Engine reset")
|
||||||
|
|
||||||
|
def get_statistics(self) -> Dict[str, Any]:
|
||||||
|
"""Get current execution statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with execution statistics
|
||||||
|
"""
|
||||||
|
return self._statistics.copy()
|
||||||
1
src/core/events.py
Normal file
1
src/core/events.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Event system."""
|
||||||
297
src/core/solver.py
Normal file
297
src/core/solver.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""Solver using CP-SAT (Constraint Programming Satisfaction).
|
||||||
|
|
||||||
|
This module implements a constraint programming solver for scheduling problems.
|
||||||
|
It uses OR-Tools CP-SAT solver for efficient constraint solving.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Variable creation and management
|
||||||
|
- Constraint definition (equality, inequality, implication)
|
||||||
|
- Objective function optimization
|
||||||
|
- Solver configuration and execution
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> from core.solver import CpSatSolver
|
||||||
|
>>> solver = CpSatSolver()
|
||||||
|
>>> solver.add_variable('agent_x', domain={0, 1, 2, 3})
|
||||||
|
>>> solver.add_constraint('agent_x > 0')
|
||||||
|
>>> solver.add_objective('minimize conflicts')
|
||||||
|
>>> result = solver.solve(scenario)
|
||||||
|
>>> print(f"Solution: {result}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
import or_tools
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SolveResult:
|
||||||
|
"""Result of solving a scheduling problem."""
|
||||||
|
assignments: Dict[str, Any]
|
||||||
|
validation_passed: bool
|
||||||
|
message: str
|
||||||
|
statistics: Dict[str, Any]
|
||||||
|
is_feasible: bool = False
|
||||||
|
objective_value: Optional[float] = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
status = "FEASIBLE" if self.is_feasible else "INFEASIBLE"
|
||||||
|
return f"SolveResult(status={status}, " \
|
||||||
|
f"assignments={len(self.assignments)}, " \
|
||||||
|
f"objective={self.objective_value})"
|
||||||
|
|
||||||
|
|
||||||
|
class CpSatSolver:
|
||||||
|
"""Constraint Programming Satisfaction (CP-SAT) solver.
|
||||||
|
|
||||||
|
This class provides a wrapper around OR-Tools CP-SAT solver for solving
|
||||||
|
scheduling problems with constraints and optimization objectives.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
scenario: Scheduling scenario data
|
||||||
|
_model: OR-Tools CP-SAT model
|
||||||
|
_status: Solver status (OPTIMAL, INFEASIBLE, etc.)
|
||||||
|
_solver: OR-Tools CP-SAT solver instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the CP-SAT solver."""
|
||||||
|
self._scenario: Optional[Dict[str, Any]] = None
|
||||||
|
self._model: Optional[or_tools.sat_solver.CpSat] = None
|
||||||
|
self._status: str = "NOT_RUN"
|
||||||
|
self._statistics: Dict[str, Any] = {
|
||||||
|
'solving_time': 0.0,
|
||||||
|
'variables_count': 0,
|
||||||
|
'constraints_count': 0,
|
||||||
|
'objectives_count': 0,
|
||||||
|
'conflicts_count': 0,
|
||||||
|
}
|
||||||
|
self._conflicts: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def initialize(self, scenario: Dict[str, Any]) -> None:
|
||||||
|
"""Initialize the solver with a scheduling scenario.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scenario: Complete scenario data including:
|
||||||
|
- agents: List of agent definitions
|
||||||
|
- events: List of event definitions
|
||||||
|
- preferences: Agent preference mappings
|
||||||
|
- constraints: List of constraints
|
||||||
|
- objectives: List of optimization objectives
|
||||||
|
- time_window: Start and end times
|
||||||
|
- max_iterations: Maximum number of iterations
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If scenario is invalid or missing required fields
|
||||||
|
"""
|
||||||
|
if scenario is None:
|
||||||
|
raise ValueError("Scenario cannot be None")
|
||||||
|
|
||||||
|
# Validate scenario structure
|
||||||
|
required_keys = ['agents', 'events', 'time_window', 'constraints',
|
||||||
|
'objectives', 'max_iterations']
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in scenario:
|
||||||
|
raise ValueError(f"Missing required key in scenario: {key}")
|
||||||
|
|
||||||
|
self._scenario = scenario
|
||||||
|
self._model = or_tools.sat_solver.CpSat()
|
||||||
|
|
||||||
|
# Configure solver parameters
|
||||||
|
self._model.parameters.max_time_in_seconds = self._scenario.get('max_time', 60.0)
|
||||||
|
self._model.parameters.max_num_workers = self._scenario.get('max_workers', 8)
|
||||||
|
self._model.parameters.log_search_progress = self._scenario.get('log_progress', True)
|
||||||
|
self._model.parameters.num_search_workers = self._scenario.get('num_search_workers', 8)
|
||||||
|
self._model.parameters.random_seed = self._scenario.get('random_seed', 42)
|
||||||
|
|
||||||
|
logger.info(f"CP-SAT solver initialized with max time: {self._model.parameters.max_time_in_seconds}s")
|
||||||
|
|
||||||
|
def add_variable(self, name: str, domain: Any, is_bool: bool = False) -> None:
|
||||||
|
"""Add a decision variable to the model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Variable name for identification
|
||||||
|
domain: Variable domain (integer range, boolean, or set of values)
|
||||||
|
is_bool: Whether variable is boolean
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If variable name already exists or model not initialized
|
||||||
|
"""
|
||||||
|
if self._model is None:
|
||||||
|
raise ValueError("Solver not initialized")
|
||||||
|
|
||||||
|
# Convert boolean to integer domain
|
||||||
|
if is_bool:
|
||||||
|
domain = {0, 1}
|
||||||
|
|
||||||
|
# Create variable
|
||||||
|
var = self._model.NewIntVar(
|
||||||
|
min(domain) if isinstance(domain, (int, float)) else min(domain),
|
||||||
|
max(domain) if isinstance(domain, (int, float)) else max(domain),
|
||||||
|
name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store variable for later use
|
||||||
|
self._model.Add(var == var) # Keep reference
|
||||||
|
logger.debug(f"Added variable: {name} with domain {domain}")
|
||||||
|
|
||||||
|
def add_constraint(self, constraint_type: str, expression: str) -> None:
|
||||||
|
"""Add a constraint to the model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
constraint_type: Type of constraint (equality, inequality, implication, etc.)
|
||||||
|
expression: Constraint expression in solver syntax
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If constraint expression is invalid
|
||||||
|
"""
|
||||||
|
if self._model is None:
|
||||||
|
raise ValueError("Solver not initialized")
|
||||||
|
|
||||||
|
# Parse and add constraint based on type
|
||||||
|
if constraint_type == 'equality':
|
||||||
|
self._model.Add(eval(expression))
|
||||||
|
elif constraint_type == 'inequality':
|
||||||
|
self._model.Add(eval(expression))
|
||||||
|
elif constraint_type == 'implication':
|
||||||
|
self._model.Add(eval(expression))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown constraint type: {constraint_type}")
|
||||||
|
|
||||||
|
logger.debug(f"Added constraint: {expression}")
|
||||||
|
|
||||||
|
def add_objective(self, objective_type: str, expression: str) -> None:
|
||||||
|
"""Add an optimization objective to the model.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
objective_type: Type of objective (minimize, maximize)
|
||||||
|
expression: Objective expression in solver syntax
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If objective expression is invalid
|
||||||
|
"""
|
||||||
|
if self._model is None:
|
||||||
|
raise ValueError("Solver not initialized")
|
||||||
|
|
||||||
|
# Parse and add objective
|
||||||
|
if objective_type == 'minimize':
|
||||||
|
self._model.Minimize(eval(expression))
|
||||||
|
elif objective_type == 'maximize':
|
||||||
|
self._model.Maximize(eval(expression))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown objective type: {objective_type}")
|
||||||
|
|
||||||
|
logger.debug(f"Added objective: {expression}")
|
||||||
|
|
||||||
|
def solve(self, scenario: Dict[str, Any]) -> SolveResult:
|
||||||
|
"""Solve the scheduling problem.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scenario: Scheduling scenario data (may be used to override parameters)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SolveResult with assignments, validation status, and statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If solver not initialized
|
||||||
|
"""
|
||||||
|
if self._model is None:
|
||||||
|
# Initialize if not initialized
|
||||||
|
self.initialize(scenario)
|
||||||
|
|
||||||
|
# Record start time
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
# Solve the model
|
||||||
|
status = self._model.Solve()
|
||||||
|
self._status = or_tools.sat_solver.StatsToStatus(status)
|
||||||
|
self._statistics['solving_time'] = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
logger.info(f"Solver status: {self._status}")
|
||||||
|
|
||||||
|
# Extract solution if feasible
|
||||||
|
if self._status == 'OPTIMAL' or self._status == 'FEASIBLE':
|
||||||
|
assignments = self._extract_assignments()
|
||||||
|
objective_value = self._model.ObjectiveValue() if self._model.HasObjective() else None
|
||||||
|
|
||||||
|
self._conflicts.clear()
|
||||||
|
|
||||||
|
return SolveResult(
|
||||||
|
assignments=assignments,
|
||||||
|
validation_passed=True,
|
||||||
|
message=f"Solution found with {len(assignments)} assignments",
|
||||||
|
statistics=self._statistics.copy(),
|
||||||
|
is_feasible=True,
|
||||||
|
objective_value=objective_value
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Extract conflicts
|
||||||
|
self._conflicts = self._extract_conflicts()
|
||||||
|
|
||||||
|
return SolveResult(
|
||||||
|
assignments={},
|
||||||
|
validation_passed=False,
|
||||||
|
message=f"Solving failed: {self._status}",
|
||||||
|
statistics=self._statistics.copy(),
|
||||||
|
is_feasible=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_assignments(self) -> Dict[str, Any]:
|
||||||
|
"""Extract variable assignments from solution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of variable assignments
|
||||||
|
"""
|
||||||
|
assignments = {}
|
||||||
|
|
||||||
|
for v in self._model.GetDeclaredVars():
|
||||||
|
var_name = str(v).split('=')[0] if '=' in str(v) else str(v)
|
||||||
|
assignments[var_name] = {
|
||||||
|
'value': v.SolutionValue(),
|
||||||
|
'domain': v.Min(), v.Max()
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments
|
||||||
|
|
||||||
|
def _extract_conflicts(self) -> Dict[str, Any]:
|
||||||
|
"""Extract conflict information from solver.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of conflicts found by solver
|
||||||
|
"""
|
||||||
|
conflicts = {}
|
||||||
|
|
||||||
|
# Extract unsat variables
|
||||||
|
unsat_vars = self._model.UnsatVar()
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
def get_statistics(self) -> Dict[str, Any]:
|
||||||
|
"""Get solver execution statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with solver statistics
|
||||||
|
"""
|
||||||
|
return self._statistics.copy()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset solver state."""
|
||||||
|
if self._model is not None:
|
||||||
|
self._model = None
|
||||||
|
|
||||||
|
self._scenario = None
|
||||||
|
self._status = "NOT_RUN"
|
||||||
|
self._statistics = {
|
||||||
|
'solving_time': 0.0,
|
||||||
|
'variables_count': 0,
|
||||||
|
'constraints_count': 0,
|
||||||
|
'objectives_count': 0,
|
||||||
|
'conflicts_count': 0,
|
||||||
|
}
|
||||||
|
self._conflicts = {}
|
||||||
|
|
||||||
|
logger.info("Solver reset")
|
||||||
360
src/core/validator.py
Normal file
360
src/core/validator.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"""Result validator for scheduling solutions."""
|
||||||
|
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultValidator:
|
||||||
|
"""Validator for scheduling results and solutions.
|
||||||
|
|
||||||
|
This class validates scheduling results and solutions against
|
||||||
|
the original scenario constraints and requirements.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
_scenario: Scheduling scenario to validate against
|
||||||
|
_errors: List of validation errors found
|
||||||
|
_warnings: List of validation warnings
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the validator."""
|
||||||
|
self._scenario: Optional[Dict[str, Any]] = None
|
||||||
|
self._errors: List[str] = []
|
||||||
|
self._warnings: List[str] = []
|
||||||
|
|
||||||
|
def validate(self, scenario: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Validate a scheduling scenario.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scenario: Complete scheduling scenario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with validation results including:
|
||||||
|
- valid: Boolean indicating validity
|
||||||
|
- errors: List of validation errors
|
||||||
|
- warnings: List of validation warnings
|
||||||
|
- corrected_scenario: Corrected scenario (if needed)
|
||||||
|
"""
|
||||||
|
self._errors.clear()
|
||||||
|
self._warnings.clear()
|
||||||
|
|
||||||
|
# Validate scenario structure
|
||||||
|
if self._validate_structure(scenario):
|
||||||
|
# Validate time window
|
||||||
|
if not self._validate_time_window(scenario):
|
||||||
|
return self._create_result(False, "Invalid time window")
|
||||||
|
|
||||||
|
# Validate agents
|
||||||
|
if not self._validate_agents(scenario):
|
||||||
|
return self._create_result(False, "Invalid agents")
|
||||||
|
|
||||||
|
# Validate events
|
||||||
|
if not self._validate_events(scenario):
|
||||||
|
return self._create_result(False, "Invalid events")
|
||||||
|
|
||||||
|
# Validate constraints
|
||||||
|
if not self._validate_constraints(scenario):
|
||||||
|
return self._create_result(False, "Invalid constraints")
|
||||||
|
|
||||||
|
# Validate objectives
|
||||||
|
if not self._validate_objectives(scenario):
|
||||||
|
return self._create_result(False, "Invalid objectives")
|
||||||
|
|
||||||
|
# Validate preferences
|
||||||
|
if not self._validate_preferences(scenario):
|
||||||
|
return self._create_result(False, "Invalid preferences")
|
||||||
|
|
||||||
|
logger.info("Scenario validation PASSED")
|
||||||
|
return self._create_result(True, "All validations passed")
|
||||||
|
|
||||||
|
return self._create_result(False, "; ".join(self._errors))
|
||||||
|
|
||||||
|
def validate_solution(self, solution: Dict[str, Any], scenario: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Validate a scheduling solution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
solution: Scheduling solution to validate
|
||||||
|
scenario: Original scenario to validate against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with validation results including:
|
||||||
|
- valid: Boolean indicating validity
|
||||||
|
- errors: List of validation errors
|
||||||
|
- warnings: List of validation warnings
|
||||||
|
"""
|
||||||
|
self._errors.clear()
|
||||||
|
self._warnings.clear()
|
||||||
|
|
||||||
|
# Validate time window
|
||||||
|
if not self._validate_time_window_solution(solution):
|
||||||
|
return self._create_result(False, "Solution outside time window")
|
||||||
|
|
||||||
|
# Validate agent assignments
|
||||||
|
if not self._validate_agent_assignments(solution, scenario):
|
||||||
|
return self._create_result(False, "Invalid agent assignments")
|
||||||
|
|
||||||
|
# Validate event assignments
|
||||||
|
if not self._validate_event_assignments(solution):
|
||||||
|
return self._create_result(False, "Invalid event assignments")
|
||||||
|
|
||||||
|
# Validate constraints
|
||||||
|
if not self._validate_solution_constraints(solution, scenario):
|
||||||
|
return self._create_result(False, "Constraint violations in solution")
|
||||||
|
|
||||||
|
# Validate preferences
|
||||||
|
if not self._validate_preferences_in_solution(solution, scenario):
|
||||||
|
return self._create_result(False, "Preference violations in solution")
|
||||||
|
|
||||||
|
# Validate objectives
|
||||||
|
if not self._validate_objectives_in_solution(solution, scenario):
|
||||||
|
return self._create_result(False, "Objective violations in solution")
|
||||||
|
|
||||||
|
logger.info("Solution validation PASSED")
|
||||||
|
return self._create_result(True, "Solution valid")
|
||||||
|
|
||||||
|
def _validate_structure(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate scenario structure."""
|
||||||
|
required_keys = ['agents', 'events', 'time_window', 'constraints',
|
||||||
|
'objectives', 'preferences']
|
||||||
|
for key in required_keys:
|
||||||
|
if key not in scenario:
|
||||||
|
self._errors.append(f"Missing key: {key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check types
|
||||||
|
if not isinstance(scenario['agents'], list):
|
||||||
|
self._errors.append("'agents' must be a list")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(scenario['events'], list):
|
||||||
|
self._errors.append("'events' must be a list")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(scenario['constraints'], list):
|
||||||
|
self._errors.append("'constraints' must be a list")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(scenario['objectives'], list):
|
||||||
|
self._errors.append("'objectives' must be a list")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(scenario['time_window'], dict):
|
||||||
|
self._errors.append("'time_window' must be a dictionary")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_time_window(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate time window."""
|
||||||
|
time_window = scenario.get('time_window', {})
|
||||||
|
if 'start' not in time_window or 'end' not in time_window:
|
||||||
|
self._errors.append("time_window must have 'start' and 'end' keys")
|
||||||
|
return False
|
||||||
|
|
||||||
|
start = time_window['start']
|
||||||
|
end = time_window['end']
|
||||||
|
|
||||||
|
if start >= end:
|
||||||
|
self._errors.append(f"Invalid time window: start ({start}) >= end ({end})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_time_window_solution(self, solution: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate solution time window."""
|
||||||
|
# This would check if all assignments are within the time window
|
||||||
|
# For now, assume scenario's time window is respected
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_agents(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate agent definitions."""
|
||||||
|
for agent in scenario['agents']:
|
||||||
|
if not isinstance(agent, dict):
|
||||||
|
self._errors.append(f"Agent must be a dictionary: {agent}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
if 'id' not in agent:
|
||||||
|
self._errors.append(f"Agent missing 'id': {agent}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'type' not in agent:
|
||||||
|
self._errors.append(f"Agent missing 'type': {agent}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'max_capacity' not in agent:
|
||||||
|
self._errors.append(f"Agent missing 'max_capacity': {agent}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'max_capacity' in agent and agent['max_capacity'] < 0:
|
||||||
|
self._errors.append(f"Invalid max_capacity for agent {agent['id']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_events(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate event definitions."""
|
||||||
|
for event in scenario['events']:
|
||||||
|
if not isinstance(event, dict):
|
||||||
|
self._errors.append(f"Event must be a dictionary: {event}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'id' not in event:
|
||||||
|
self._errors.append(f"Event missing 'id': {event}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'type' not in event:
|
||||||
|
self._errors.append(f"Event missing 'type': {event}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'duration' in event and event['duration'] < 0:
|
||||||
|
self._errors.append(f"Invalid duration for event {event['id']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_constraints(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate constraints."""
|
||||||
|
for constraint in scenario['constraints']:
|
||||||
|
if not isinstance(constraint, dict):
|
||||||
|
self._errors.append(f"Constraint must be a dictionary: {constraint}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'type' not in constraint:
|
||||||
|
self._errors.append(f"Constraint missing 'type': {constraint}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_objectives(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate objectives."""
|
||||||
|
for objective in scenario['objectives']:
|
||||||
|
if not isinstance(objective, dict):
|
||||||
|
self._errors.append(f"Objective must be a dictionary: {objective}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'type' not in objective:
|
||||||
|
self._errors.append(f"Objective missing 'type': {objective}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_preferences(self, scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate preferences."""
|
||||||
|
if not isinstance(scenario.get('preferences'), dict):
|
||||||
|
self._errors.append("'preferences' must be a dictionary")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for agent_id, prefs in scenario['preferences'].items():
|
||||||
|
if not isinstance(prefs, dict):
|
||||||
|
self._errors.append(f"Preferences for agent {agent_id} must be a dictionary")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for preference in prefs.get('preferences', []):
|
||||||
|
if not isinstance(preference, dict):
|
||||||
|
self._errors.append(f"Preference must be a dictionary: {preference}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_agent_assignments(self, solution: Dict[str, Any], scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate agent assignments in solution."""
|
||||||
|
# Check each agent has assignments that don't exceed capacity
|
||||||
|
for agent_id, assignments in solution.get('assignments', {}).items():
|
||||||
|
if isinstance(assignments, list):
|
||||||
|
# Agent can have multiple assignments
|
||||||
|
if len(assignments) > scenario['agents'][agent_id].get('max_capacity', 1):
|
||||||
|
self._errors.append(
|
||||||
|
f"Agent {agent_id} exceeds capacity: {len(assignments)} > {scenario['agents'][agent_id].get('max_capacity', 1)}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Agent has single assignment
|
||||||
|
if scenario['agents'][agent_id].get('max_capacity', 1) == 1:
|
||||||
|
if agent_id in solution.get('assignments', {}):
|
||||||
|
if scenario['agents'][agent_id].get('max_capacity', 1) < 1:
|
||||||
|
self._errors.append(
|
||||||
|
f"Agent {agent_id} has assignment but capacity is 0"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_event_assignments(self, solution: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate event assignments in solution."""
|
||||||
|
for event_id, assignments in solution.get('assignments', {}).items():
|
||||||
|
if not isinstance(assignments, dict):
|
||||||
|
self._errors.append(f"Assignment for event {event_id} must be a dictionary")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if assigned to valid agent
|
||||||
|
event = next((e for e in scenario['events'] if e['id'] == event_id), None)
|
||||||
|
if event:
|
||||||
|
agent_id = assignments.get('agent')
|
||||||
|
if agent_id not in scenario['agents']:
|
||||||
|
self._errors.append(
|
||||||
|
f"Event {event_id} assigned to non-existent agent: {agent_id}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_solution_constraints(self, solution: Dict[str, Any], scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate solution against constraints."""
|
||||||
|
# This would check each constraint type against the solution
|
||||||
|
# For now, just return True as a placeholder
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_preferences_in_solution(self, solution: Dict[str, Any], scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate preferences in solution."""
|
||||||
|
for agent_id, prefs in scenario.get('preferences', {}).items():
|
||||||
|
assignments = solution.get('assignments', {}).get(agent_id, [])
|
||||||
|
|
||||||
|
# Validate first choice
|
||||||
|
if isinstance(assignments, list) and len(assignments) > 0:
|
||||||
|
first_assignment = assignments[0]
|
||||||
|
event_id = next((e['id'] for e in scenario['events'] if e['id'] == first_assignment.get('event_id')), None)
|
||||||
|
|
||||||
|
if event_id:
|
||||||
|
event_type = next((e['type'] for e in scenario['events'] if e['id'] == event_id), None)
|
||||||
|
if event_type:
|
||||||
|
# Check if first choice is valid
|
||||||
|
if event_type in prefs.get('preferences', {}):
|
||||||
|
if prefs['preferences'][event_type] != 0: # Not best choice
|
||||||
|
self._warnings.append(
|
||||||
|
f"Agent {agent_id} assigned to non-preferred event: {event_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _validate_objectives_in_solution(self, solution: Dict[str, Any], scenario: Dict[str, Any]) -> bool:
|
||||||
|
"""Validate objectives in solution."""
|
||||||
|
# This would check if solution meets objective constraints
|
||||||
|
# For now, just return True as a placeholder
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _create_result(self, valid: bool, message: str) -> Dict[str, Any]:
|
||||||
|
"""Create a validation result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
valid: Whether validation passed
|
||||||
|
message: Validation message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with validation result
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'valid': valid,
|
||||||
|
'errors': self._errors.copy(),
|
||||||
|
'warnings': self._warnings.copy(),
|
||||||
|
'message': message,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset validator state."""
|
||||||
|
self._scenario = None
|
||||||
|
self._errors = []
|
||||||
|
self._warnings = []
|
||||||
1
src/domains/__init__.py
Normal file
1
src/domains/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Domains package."""
|
||||||
26
src/domains/models/__init__.py
Normal file
26
src/domains/models/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Domain models package."""
|
||||||
|
|
||||||
|
from domains.models.operator import (
|
||||||
|
Operator,
|
||||||
|
ShiftType,
|
||||||
|
)
|
||||||
|
from domains.models.shift import (
|
||||||
|
Shift,
|
||||||
|
ShiftTypeEnum,
|
||||||
|
)
|
||||||
|
from domains.models.preference import (
|
||||||
|
OperatorPreferences,
|
||||||
|
Preference,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Операторы
|
||||||
|
"Operator",
|
||||||
|
"ShiftType",
|
||||||
|
"OperatorPreferences",
|
||||||
|
"Preference",
|
||||||
|
# Смены
|
||||||
|
"Shift",
|
||||||
|
"ShiftTypeEnum",
|
||||||
|
]
|
||||||
|
|
||||||
118
src/domains/models/operator.py
Normal file
118
src/domains/models/operator.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Модель оператора (Employee/Staff member)
|
||||||
|
|
||||||
|
Класс, представляющий работника с его информацией и предпочтениями.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftType(Enum):
|
||||||
|
"""Типы смен"""
|
||||||
|
MORNING = "Morning" # Утро
|
||||||
|
AFTERNOON = "Afternoon" # День
|
||||||
|
EVENING = "Evening" # Вечер
|
||||||
|
NIGHT = "Night" # Ночь
|
||||||
|
OFF = "Off" # Отдых
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operator:
|
||||||
|
"""
|
||||||
|
Модель оператора (сотрудника)
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Уникальный идентификатор оператора
|
||||||
|
name: Имя оператора
|
||||||
|
department: Отдел/сектор
|
||||||
|
phone: Телефонный номер
|
||||||
|
email: Электронная почта
|
||||||
|
skills: Навыки (список типов смен, на которых может работать)
|
||||||
|
preferred_days: Предпочитаемые дни недели (0-6, где 0 = понедельник)
|
||||||
|
max_shifts_per_week: Максимум смен в неделю (по умолчанию 5)
|
||||||
|
min_rest_days: Минимум дней отдыха между сменами (по умолчанию 1)
|
||||||
|
max_weekly_hours: Максимум рабочих часов в неделю (по умолчанию 40)
|
||||||
|
notes: Дополнительные примечания
|
||||||
|
"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
department: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
skills: List[str] = None # Список разрешённых типов смен
|
||||||
|
preferred_days: List[int] = None # Предпочитаемые дни (0=Понедельник)
|
||||||
|
max_shifts_per_week: int = 5
|
||||||
|
min_rest_days: int = 1
|
||||||
|
max_weekly_hours: int = 40
|
||||||
|
notes: str = None
|
||||||
|
_assigned_shifts: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Инициализация списков по умолчанию"""
|
||||||
|
if self.skills is None:
|
||||||
|
self.skills = ["Morning", "Afternoon", "Evening", "Night"] # Все смены по умолчанию
|
||||||
|
if self.preferred_days is None:
|
||||||
|
self.preferred_days = list(range(0, 7)) # Все дни по умолчанию
|
||||||
|
if self.department is None:
|
||||||
|
self.department = "General"
|
||||||
|
|
||||||
|
# Преобразуем названия смен в ShiftType enum
|
||||||
|
self.skills_enum = [ShiftType[s.upper()] for s in self.skills if s in ShiftType.__members__]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def assigned_shifts(self) -> int:
|
||||||
|
"""Количество назначенных смен"""
|
||||||
|
return getattr(self, '_assigned_shifts', 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_for_assignment(self) -> bool:
|
||||||
|
"""Можно ли назначить смену"""
|
||||||
|
return self.assigned_shifts < self.max_shifts_per_week
|
||||||
|
|
||||||
|
def can_work_shift(self, shift_type: ShiftType) -> bool:
|
||||||
|
"""Проверяет, может ли оператор работать на указанном типе смены"""
|
||||||
|
return shift_type in self.skills_enum
|
||||||
|
|
||||||
|
def get_availability(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Возвращает информацию о доступности оператора
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Словарь с данными о доступности
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"operator_id": self.id,
|
||||||
|
"total_shifts_assigned": self.assigned_shifts,
|
||||||
|
"available_for_assignment": self.available_for_assignment,
|
||||||
|
"max_shifts_per_week": self.max_shifts_per_week,
|
||||||
|
"max_weekly_hours": self.max_weekly_hours
|
||||||
|
}
|
||||||
|
|
||||||
|
def assign_shift(self, shift):
|
||||||
|
"""
|
||||||
|
Назначает смену оператору
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shift: Объект Shift
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Если смена уже назначена или превышен лимит
|
||||||
|
"""
|
||||||
|
if self.assigned_shifts >= self.max_shifts_per_week:
|
||||||
|
raise ValueError(f"Оператор {self.name} достиг максимума смен в неделю")
|
||||||
|
|
||||||
|
self._assigned_shifts += 1
|
||||||
|
|
||||||
|
def release_shift(self):
|
||||||
|
"""Отменяет последнюю назначенную смену"""
|
||||||
|
if hasattr(self, '_assigned_shifts'):
|
||||||
|
self._assigned_shifts -= 1
|
||||||
|
if self._assigned_shifts < 0:
|
||||||
|
self._assigned_shifts = 0
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.id})"
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Operator(id='{self.id}', name='{self.name}', dept='{self.department}')"
|
||||||
203
src/domains/models/preference.py
Normal file
203
src/domains/models/preference.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Модель предпочтений оператора
|
||||||
|
|
||||||
|
Хранит предпочтения оператора по дням, типам смен и особым условиям.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict, Set
|
||||||
|
from enum import Enum
|
||||||
|
from domains.models.shift import Shift, ShiftTypeEnum
|
||||||
|
from domains.models.operator import Operator
|
||||||
|
|
||||||
|
|
||||||
|
class PreferenceTypeEnum(Enum):
|
||||||
|
"""Типы предпочтений"""
|
||||||
|
SHIFT_PREFERENCE = "ShiftPreference" # Предпочтение по типу смены
|
||||||
|
DAY_PREFERENCE = "DayPreference" # Предпочтение по дню
|
||||||
|
TIME_PREFERENCE = "TimePreference" # Предпочтение по времени
|
||||||
|
REST_PREFERENCE = "RestPreference" # Предпочтение по отдыху
|
||||||
|
SEQUENCE_PREFERENCE = "SequencePreference" # Предпочтение по последовательности
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Preference:
|
||||||
|
"""
|
||||||
|
Модель предпочтения оператора
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
operator_id: ID оператора
|
||||||
|
shift_type: Предпочитаемый тип смены
|
||||||
|
days: Предпочитаемые дни (0=Понедельник, ..., 6=Воскресенье)
|
||||||
|
time_preference: Предпочитаемое время (Morning/Afternoon/Evening)
|
||||||
|
sequence_preference: Предпочитаемая последовательность смен
|
||||||
|
strength: Сила предпочтения (0-100)
|
||||||
|
reason: Объяснение предпочтения
|
||||||
|
"""
|
||||||
|
operator_id: str
|
||||||
|
shift_type: Optional[ShiftTypeEnum] = None
|
||||||
|
days: Optional[Set[int]] = None
|
||||||
|
time_preference: Optional[ShiftTypeEnum] = None
|
||||||
|
sequence_preference: Optional[str] = None
|
||||||
|
strength: int = 50 # 50 - средний вес, 0-100
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Инициализация множеств по умолчанию"""
|
||||||
|
if self.days is None:
|
||||||
|
self.days = set()
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Превращает предпочтения в словарь"""
|
||||||
|
return {
|
||||||
|
"operator_id": self.operator_id,
|
||||||
|
"shift_type": self.shift_type.value if self.shift_type else None,
|
||||||
|
"days": list(self.days) if self.days else [],
|
||||||
|
"time_preference": self.time_preference.value if self.time_preference else None,
|
||||||
|
"sequence_preference": self.sequence_preference,
|
||||||
|
"strength": self.strength,
|
||||||
|
"reason": self.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
def merge_preference(self, other: 'Preference') -> None:
|
||||||
|
"""
|
||||||
|
Объединяет предпочтения с другим предпочтением
|
||||||
|
|
||||||
|
Args:
|
||||||
|
other: Другое предпочтение
|
||||||
|
"""
|
||||||
|
if other.shift_type:
|
||||||
|
if self.shift_type and self.shift_type != other.shift_type:
|
||||||
|
raise ValueError("Конфликтующие предпочтения по типу смены")
|
||||||
|
else:
|
||||||
|
self.shift_type = other.shift_type
|
||||||
|
|
||||||
|
if other.days:
|
||||||
|
self.days.update(other.days)
|
||||||
|
|
||||||
|
if other.time_preference:
|
||||||
|
if self.time_preference and self.time_preference != other.time_preference:
|
||||||
|
raise ValueError("Конфликтующие предпочтения по времени")
|
||||||
|
else:
|
||||||
|
self.time_preference = other.time_preference
|
||||||
|
|
||||||
|
if other.sequence_preference:
|
||||||
|
if self.sequence_preference and self.sequence_preference != other.sequence_preference:
|
||||||
|
raise ValueError("Конфликтующие предпочтения по последовательности")
|
||||||
|
else:
|
||||||
|
self.sequence_preference = other.sequence_preference
|
||||||
|
|
||||||
|
if other.reason:
|
||||||
|
if not self.reason:
|
||||||
|
self.reason = other.reason
|
||||||
|
else:
|
||||||
|
self.reason += f", {other.reason}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OperatorPreferences:
|
||||||
|
"""
|
||||||
|
Коллекция предпочтений для одного оператора
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
operator_id: ID оператора
|
||||||
|
shift_preferences: Список предпочтений по сменам
|
||||||
|
day_preferences: Список предпочтений по дням
|
||||||
|
time_preferences: Список предпочтений по времени
|
||||||
|
rest_preferences: Список предпочтений по отдыху
|
||||||
|
sequence_preferences: Список предпочтений по последовательности
|
||||||
|
"""
|
||||||
|
operator_id: str
|
||||||
|
shift_preferences: List['Preference'] = field(default_factory=list)
|
||||||
|
day_preferences: List['Preference'] = field(default_factory=list)
|
||||||
|
time_preferences: List['Preference'] = field(default_factory=list)
|
||||||
|
rest_preferences: List['Preference'] = field(default_factory=list)
|
||||||
|
sequence_preferences: List['Preference'] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add_shift_preference(self, shift_type: ShiftTypeEnum, strength: int = 50, reason: str = None) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет предпочтение по типу смены
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shift_type: Тип смены
|
||||||
|
strength: Сила предпочтения
|
||||||
|
reason: Объяснение
|
||||||
|
"""
|
||||||
|
pref = Preference(
|
||||||
|
operator_id=self.operator_id,
|
||||||
|
shift_type=shift_type,
|
||||||
|
strength=strength,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
self.shift_preferences.append(pref)
|
||||||
|
|
||||||
|
def add_day_preference(self, days: Set[int], strength: int = 50, reason: str = None) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет предпочтение по дням
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Множество дней недели
|
||||||
|
strength: Сила предпочтения
|
||||||
|
reason: Объяснение
|
||||||
|
"""
|
||||||
|
pref = Preference(
|
||||||
|
operator_id=self.operator_id,
|
||||||
|
days=days,
|
||||||
|
strength=strength,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
self.day_preferences.append(pref)
|
||||||
|
|
||||||
|
def add_time_preference(self, time_pref: ShiftTypeEnum, strength: int = 50, reason: str = None) -> None:
|
||||||
|
"""
|
||||||
|
Добавляет предпочтение по времени
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_pref: Тип смены
|
||||||
|
strength: Сила предпочтения
|
||||||
|
reason: Объяснение
|
||||||
|
"""
|
||||||
|
pref = Preference(
|
||||||
|
operator_id=self.operator_id,
|
||||||
|
time_preference=time_pref,
|
||||||
|
strength=strength,
|
||||||
|
reason=reason
|
||||||
|
)
|
||||||
|
self.time_preferences.append(pref)
|
||||||
|
|
||||||
|
def get_best_shift_type(self) -> Optional[ShiftTypeEnum]:
|
||||||
|
"""
|
||||||
|
Возвращает лучший тип смены для оператора на основе предпочтений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ShiftTypeEnum или None
|
||||||
|
"""
|
||||||
|
if not self.shift_preferences:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Сортируем предпочтения по силе
|
||||||
|
sorted_prefs = sorted(self.shift_preferences, key=lambda p: p.strength, reverse=True)
|
||||||
|
if sorted_prefs:
|
||||||
|
return sorted_prefs[0].shift_type
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_best_days(self) -> Optional[Set[int]]:
|
||||||
|
"""
|
||||||
|
Возвращает лучшие дни для оператора
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set[int] или None
|
||||||
|
"""
|
||||||
|
if not self.day_preferences:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Объединяем дни из всех предпочтений
|
||||||
|
all_days = set()
|
||||||
|
for pref in self.day_preferences:
|
||||||
|
all_days.update(pref.days)
|
||||||
|
|
||||||
|
return all_days if all_days else None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"OperatorPreferences(operator_id={self.operator_id}, shift_prefs={len(self.shift_preferences)}, day_prefs={len(self.day_preferences)})"
|
||||||
1
src/domains/models/schedule.py
Normal file
1
src/domains/models/schedule.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Schedule model."""
|
||||||
153
src/domains/models/shift.py
Normal file
153
src/domains/models/shift.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Shift model."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
import dateutil.relativedelta
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftTypeEnum(Enum):
|
||||||
|
"""Типы смен"""
|
||||||
|
MORNING = "Morning" # 08:00 - 16:00
|
||||||
|
AFTERNOON = "Afternoon" # 13:00 - 21:00
|
||||||
|
EVENING = "Evening" # 21:00 - 01:00
|
||||||
|
NIGHT = "Night" # 01:00 - 09:00
|
||||||
|
OFF = "Off" # Отдых
|
||||||
|
WEEKEND = "Weekend" # Выходной
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Shift:
|
||||||
|
"""
|
||||||
|
Модель смены
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
date: Дата смены (datetime.date)
|
||||||
|
shift_type: Тип смены (Морнинг, Afternoon и т.д.)
|
||||||
|
start_time: Время начала смены (HH:MM)
|
||||||
|
end_time: Время окончания смены (HH:MM)
|
||||||
|
department_required: Требуемый отдел (опционально)
|
||||||
|
required_skills: Требуемые навыки/квалификации
|
||||||
|
min_operators: Минимум операторов на смене
|
||||||
|
max_operators: Максимум операторов на смене
|
||||||
|
description: Описание смены
|
||||||
|
is_priority: Является ли смена приоритетной
|
||||||
|
notes: Дополнительные примечания
|
||||||
|
"""
|
||||||
|
date: datetime.date
|
||||||
|
shift_type: ShiftTypeEnum = ShiftTypeEnum.MORNING
|
||||||
|
start_time: Optional[str] = "08:00"
|
||||||
|
end_time: Optional[str] = "16:00"
|
||||||
|
department_required: Optional[str] = None
|
||||||
|
required_skills: Optional[list] = None
|
||||||
|
min_operators: int = 1
|
||||||
|
max_operators: int = 1
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_priority: bool = False
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_datetime(cls, dt: datetime.date, shift_type: str = "Morning", **kwargs) -> 'Shift':
|
||||||
|
"""
|
||||||
|
Создает смену из datetime
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Дата и время
|
||||||
|
shift_type: Тип смены
|
||||||
|
**kwargs: Дополнительные параметры
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shift: Объект смены
|
||||||
|
"""
|
||||||
|
start_time = dt.strftime("%H:%M")
|
||||||
|
|
||||||
|
# Определяем тип смены по времени
|
||||||
|
hour = int(start_time.split(':')[0])
|
||||||
|
if 8 <= hour < 13:
|
||||||
|
shift_type_enum = ShiftTypeEnum.MORNING
|
||||||
|
elif 13 <= hour < 21:
|
||||||
|
shift_type_enum = ShiftTypeEnum.AFTERNOON
|
||||||
|
elif 21 <= hour < 25:
|
||||||
|
shift_type_enum = ShiftTypeEnum.EVENING
|
||||||
|
elif hour >= 0:
|
||||||
|
shift_type_enum = ShiftTypeEnum.NIGHT
|
||||||
|
else:
|
||||||
|
shift_type_enum = ShiftTypeEnum.OFF
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
date=dt,
|
||||||
|
shift_type=shift_type_enum,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=(hour + 8) % 24 if hour < 16 else f"{hour + 8}:00" if hour < 20 else f"{hour + 8}:00",
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_weekly(cls, num_days: int = 7, shift_type: str = "Morning") -> list:
|
||||||
|
"""
|
||||||
|
Генерирует сменные план на указанный период
|
||||||
|
|
||||||
|
Args:
|
||||||
|
num_days: Количество дней (по умолчанию 7)
|
||||||
|
shift_type: Тип смены по умолчанию
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Список смен
|
||||||
|
"""
|
||||||
|
shifts = []
|
||||||
|
for day in range(num_days):
|
||||||
|
shift_date = datetime.date.today() + dateutil.relativedelta.relativedelta(days=day)
|
||||||
|
shifts.append(cls(
|
||||||
|
date=shift_date,
|
||||||
|
shift_type=ShiftTypeEnum[shift_type],
|
||||||
|
is_priority=True
|
||||||
|
))
|
||||||
|
return shifts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_datetime(self) -> datetime:
|
||||||
|
"""Возвращает время начала смены в формате datetime"""
|
||||||
|
return datetime.combine(self.date, datetime.strptime(self.start_time, "%H:%M").time())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def end_datetime(self) -> datetime:
|
||||||
|
"""Возвращает время окончания смены в формате datetime"""
|
||||||
|
return datetime.combine(self.date, datetime.strptime(self.end_time, "%H:%M").time())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration_hours(self) -> int:
|
||||||
|
"""Длительность смены в часах"""
|
||||||
|
if self.start_time and self.end_time:
|
||||||
|
start_h, start_m = map(int, self.start_time.split(':'))
|
||||||
|
end_h, end_m = map(int, self.end_time.split(':'))
|
||||||
|
total_minutes = (end_h - start_h) * 60 + (end_m - start_m)
|
||||||
|
# Корректировка для ночных смен
|
||||||
|
if total_minutes < 0:
|
||||||
|
total_minutes += 24 * 60
|
||||||
|
return total_minutes // 60
|
||||||
|
return 8 # По умолчанию 8 часов
|
||||||
|
|
||||||
|
def is_available(self, operator) -> bool:
|
||||||
|
"""
|
||||||
|
Проверяет, доступен ли оператор для этой смены
|
||||||
|
|
||||||
|
Args:
|
||||||
|
operator: Объект Operator
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True если оператор может работать на этой смене
|
||||||
|
"""
|
||||||
|
# Проверка навыков
|
||||||
|
if self.required_skills:
|
||||||
|
operator_skills = getattr(operator, 'skills', [])
|
||||||
|
if any(skill in self.required_skills for skill in operator_skills):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.date.strftime('%Y-%m-%d')} {self.shift_type.value} {self.start_time}-{self.end_time}"
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Shift(date='{self.date}', type='{self.shift_type.value}')"
|
||||||
1
src/domains/repositories/__init__.py
Normal file
1
src/domains/repositories/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Repositories package."""
|
||||||
1
src/domains/repositories/operator_repo.py
Normal file
1
src/domains/repositories/operator_repo.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Operator repository."""
|
||||||
1
src/domains/repositories/schedule_repo.py
Normal file
1
src/domains/repositories/schedule_repo.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Schedule repository."""
|
||||||
1
src/domains/repositories/shift_repo.py
Normal file
1
src/domains/repositories/shift_repo.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Shift repository."""
|
||||||
1
src/domains/services/__init__.py
Normal file
1
src/domains/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Services package."""
|
||||||
1
src/domains/services/generator.py
Normal file
1
src/domains/services/generator.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Schedule generator service."""
|
||||||
1
src/domains/services/optimizer.py
Normal file
1
src/domains/services/optimizer.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Optimizer service."""
|
||||||
1
src/exceptions.py
Normal file
1
src/exceptions.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Custom exceptions."""
|
||||||
8
src/logging_config.py
Normal file
8
src/logging_config.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Logging configuration."""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
@@ -1 +1 @@
|
|||||||
# Тесты для проекта модульности конфигурации
|
"""Tests package."""
|
||||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Integration tests package."""
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""
|
|
||||||
test_config.py - Тесты для конфигурации
|
|
||||||
Проверяют корректность чтения и структуры config.py
|
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from config import YEAR, MONTH, SHIFTS, OPERATORS, OPERATOR_PREFERENCES
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigConstants:
|
|
||||||
"""Тесты для констант конфигурации"""
|
|
||||||
|
|
||||||
def test_year_is_int(self):
|
|
||||||
"""Год должен быть целым числом"""
|
|
||||||
assert isinstance(YEAR, int)
|
|
||||||
assert YEAR == 2026
|
|
||||||
|
|
||||||
def test_month_is_int(self):
|
|
||||||
"""Месяц должен быть целым числом"""
|
|
||||||
assert isinstance(MONTH, int)
|
|
||||||
assert MONTH == 4
|
|
||||||
|
|
||||||
def test_month_in_range(self):
|
|
||||||
"""Месяц должен быть в диапазоне 1-12"""
|
|
||||||
assert 1 <= MONTH <= 12
|
|
||||||
|
|
||||||
def test_shifts_is_list(self):
|
|
||||||
"""Смены должны быть списком"""
|
|
||||||
assert isinstance(SHIFTS, list)
|
|
||||||
assert len(SHIFTS) == 4
|
|
||||||
|
|
||||||
def test_shifts_structure(self):
|
|
||||||
"""Каждая смена должна быть кортежем из двух чисел"""
|
|
||||||
for shift in SHIFTS:
|
|
||||||
assert isinstance(shift, tuple)
|
|
||||||
assert len(shift) == 2
|
|
||||||
start, end = shift
|
|
||||||
assert isinstance(start, (int, float))
|
|
||||||
assert isinstance(end, (int, float))
|
|
||||||
assert start < end
|
|
||||||
|
|
||||||
def test_operators_is_list(self):
|
|
||||||
"""Операторы должны быть списком"""
|
|
||||||
assert isinstance(OPERATORS, list)
|
|
||||||
assert len(OPERATORS) > 0
|
|
||||||
|
|
||||||
def test_operators_not_empty(self):
|
|
||||||
"""Список операторов не должен быть пустым"""
|
|
||||||
assert len(OPERATORS) >= 1
|
|
||||||
|
|
||||||
def test_operators_strings(self):
|
|
||||||
"""Все операторы должны быть строками"""
|
|
||||||
for op in OPERATORS:
|
|
||||||
assert isinstance(op, str)
|
|
||||||
assert len(op) > 0
|
|
||||||
|
|
||||||
def test_operator_preferences_structure(self):
|
|
||||||
"""Структура предпочтений должна быть корректной"""
|
|
||||||
assert isinstance(OPERATOR_PREFERENCES, dict)
|
|
||||||
assert len(OPERATOR_PREFERENCES) == len(OPERATORS)
|
|
||||||
|
|
||||||
def test_operator_preferences_values(self):
|
|
||||||
"""Значения предпочтений должны быть словарями"""
|
|
||||||
for op, prefs in OPERATOR_PREFERENCES.items():
|
|
||||||
assert isinstance(prefs, dict)
|
|
||||||
# Проверка, что ключи предпочтений корректны
|
|
||||||
valid_keys = {'dates', 'day_of_week', 'shift', 'off_dates'}
|
|
||||||
assert set(prefs.keys()).issubset(valid_keys)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDates:
|
|
||||||
"""Тесты для дат"""
|
|
||||||
|
|
||||||
def test_year_is_positive(self):
|
|
||||||
"""Год должен быть положительным"""
|
|
||||||
assert YEAR > 0
|
|
||||||
|
|
||||||
def test_month_is_valid(self):
|
|
||||||
"""Месяц должен быть валидным (1-12)"""
|
|
||||||
assert 1 <= MONTH <= 12
|
|
||||||
|
|
||||||
def test_shifts_time_valid(self):
|
|
||||||
"""Время смены должно быть валидным (0-24)"""
|
|
||||||
for start, end in SHIFTS:
|
|
||||||
assert 0 <= start <= 24
|
|
||||||
assert 0 <= end <= 24
|
|
||||||
assert start < end
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigOperators:
|
|
||||||
"""Тесты для операторов"""
|
|
||||||
|
|
||||||
def test_operators_unique(self):
|
|
||||||
"""Имена операторов должны быть уникальными"""
|
|
||||||
assert len(OPERATORS) == len(set(OPERATORS))
|
|
||||||
|
|
||||||
def test_operators_no_empty_strings(self):
|
|
||||||
"""Имена операторов не должны быть пустыми строками"""
|
|
||||||
for op in OPERATORS:
|
|
||||||
assert op.strip() != ""
|
|
||||||
|
|
||||||
def test_operators_no_special_chars(self):
|
|
||||||
"""Имена операторов не должны содержать специальные символы"""
|
|
||||||
for op in OPERATORS:
|
|
||||||
assert not any(c in op for c in ['<', '>', '/', '\\', '|', ':', '*', '?', '"'])
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigPreferences:
|
|
||||||
"""Тесты для предпочтений операторов"""
|
|
||||||
|
|
||||||
def test_all_operators_have_preferences(self):
|
|
||||||
"""Все операторы должны иметь предпочтения"""
|
|
||||||
assert len(OPERATOR_PREFERENCES) == len(OPERATORS)
|
|
||||||
|
|
||||||
def test_dates_in_range(self):
|
|
||||||
"""Дни в предпочтениях должны быть в диапазоне 1-31"""
|
|
||||||
for prefs in OPERATOR_PREFERENCES.values():
|
|
||||||
if 'dates' in prefs:
|
|
||||||
for day in prefs['dates']:
|
|
||||||
assert 1 <= day <= 31
|
|
||||||
|
|
||||||
def test_shift_in_range(self):
|
|
||||||
"""Смена в предпочтениях должна быть в диапазоне 0-3"""
|
|
||||||
for prefs in OPERATOR_PREFERENCES.values():
|
|
||||||
if 'shift' in prefs:
|
|
||||||
assert 0 <= prefs['shift'] <= 3
|
|
||||||
|
|
||||||
def test_day_of_week_valid(self):
|
|
||||||
"""День недели в предпочтениях должен быть валидным"""
|
|
||||||
for prefs in OPERATOR_PREFERENCES.values():
|
|
||||||
if 'day_of_week' in prefs:
|
|
||||||
day_str = prefs['day_of_week']
|
|
||||||
# Проверяем, что это строка с валидным значением
|
|
||||||
assert day_str.lower() in ['понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота', 'воскресенье']
|
|
||||||
|
|
||||||
def test_off_dates_in_range(self):
|
|
||||||
"""Дни отдыха должны быть в диапазоне 1-31"""
|
|
||||||
for prefs in OPERATOR_PREFERENCES.values():
|
|
||||||
if 'off_dates' in prefs:
|
|
||||||
for day in prefs['off_dates']:
|
|
||||||
assert 1 <= day <= 31
|
|
||||||
@@ -1,354 +1,137 @@
|
|||||||
"""
|
"""Тест моделей данных для операторов и смен"""
|
||||||
test_models.py - Тесты для моделей и предпочтений
|
import sys
|
||||||
Проверяют корректность работы класса OperatorPreferences и связанных функций
|
sys.path.insert(0, 'src')
|
||||||
"""
|
|
||||||
import pytest
|
|
||||||
from datetime import datetime
|
|
||||||
from models import (
|
|
||||||
OperatorPreferences,
|
|
||||||
PreferencesBuilder,
|
|
||||||
DayOfWeek,
|
|
||||||
PreferenceType,
|
|
||||||
get_days,
|
|
||||||
create_model,
|
|
||||||
solve_model,
|
|
||||||
get_solution
|
|
||||||
)
|
|
||||||
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
|
||||||
|
|
||||||
class TestDayOfWeek:
|
from datetime import date, datetime
|
||||||
"""Тесты для enum DayOfWeek"""
|
from domains.models.operator import Operator, ShiftType
|
||||||
|
from domains.models.shift import Shift, ShiftTypeEnum
|
||||||
def test_day_of_week_values(self):
|
from domains.models.preference import OperatorPreferences, Preference
|
||||||
"""Проверка значений enum"""
|
|
||||||
assert DayOfWeek.MONDAY.value == 0
|
|
||||||
assert DayOfWeek.TUESDAY.value == 1
|
|
||||||
assert DayOfWeek.WEDNESDAY.value == 2
|
|
||||||
assert DayOfWeek.THURSDAY.value == 3
|
|
||||||
assert DayOfWeek.FRIDAY.value == 4
|
|
||||||
assert DayOfWeek.SATURDAY.value == 5
|
|
||||||
assert DayOfWeek.SUNDAY.value == 6
|
|
||||||
|
|
||||||
def test_day_of_week_names(self):
|
|
||||||
"""Проверка названий enum"""
|
|
||||||
assert DayOfWeek.MONDAY.name == "MONDAY"
|
|
||||||
assert DayOfWeek.SUNDAY.name == "SUNDAY"
|
|
||||||
|
|
||||||
|
|
||||||
class TestOperatorPreferences:
|
def test_operator():
|
||||||
"""Тесты для класса OperatorPreferences"""
|
"""Тест модели оператора"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("TEST: Model Operator")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
def test_creation(self):
|
# Создание оператора
|
||||||
"""Тест создания объекта предпочтений"""
|
operator = Operator(
|
||||||
prefs = OperatorPreferences("Тестовый")
|
id="OP001",
|
||||||
assert prefs.operator_name == "Тестовый"
|
name="Иванов Иван Иванович",
|
||||||
assert prefs.work_on_dates == []
|
department="Склад",
|
||||||
assert prefs.work_on_day_of_week == []
|
email="ivanov@company.com",
|
||||||
assert prefs.work_only_shift is None
|
skills=["Morning", "Afternoon", "Evening"],
|
||||||
assert prefs.off_on_dates == []
|
preferred_days=[1, 2, 3, 4, 5],
|
||||||
|
max_shifts_per_week=4,
|
||||||
|
max_weekly_hours=32
|
||||||
|
)
|
||||||
|
|
||||||
def test_to_dict_empty(self):
|
print(f"\nOperator: {operator}")
|
||||||
"""Тест конвертации в словарь для пустых предпочтений"""
|
print(f"ID: {operator.id}")
|
||||||
prefs = OperatorPreferences("Тестовый")
|
print(f"Name: {operator.name}")
|
||||||
result = prefs.to_dict()
|
print(f"Department: {operator.department}")
|
||||||
assert result == {}
|
print(f"Skills: {operator.skills}")
|
||||||
|
print(f"Preferred days: {operator.preferred_days}")
|
||||||
|
print(f"Can work 'Night' shift: {operator.can_work_shift(ShiftType.NIGHT)}")
|
||||||
|
|
||||||
def test_to_dict_with_dates(self):
|
# Проверка доступности
|
||||||
"""Тест конвертации с датами"""
|
availability = operator.get_availability()
|
||||||
prefs = OperatorPreferences("Тестовый")
|
print(f"\nAvailability info:")
|
||||||
prefs.work_on_dates = [10, 15, 20]
|
print(f" Total shifts assigned: {availability['total_shifts_assigned']}")
|
||||||
result = prefs.to_dict()
|
print(f" Available for assignment: {availability['available_for_assignment']}")
|
||||||
assert result['dates'] == [10, 15, 20]
|
|
||||||
|
|
||||||
def test_to_dict_with_shift(self):
|
|
||||||
"""Тест конвертации со сменой"""
|
|
||||||
prefs = OperatorPreferences("Тестовый")
|
|
||||||
prefs.work_only_shift = 2
|
|
||||||
result = prefs.to_dict()
|
|
||||||
assert result['shift'] == 2
|
|
||||||
|
|
||||||
def test_to_dict_with_day_of_week(self):
|
|
||||||
"""Тест конвертации с днем недели"""
|
|
||||||
prefs = OperatorPreferences("Тестовый")
|
|
||||||
prefs.work_on_day_of_week = [DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY]
|
|
||||||
result = prefs.to_dict()
|
|
||||||
assert result['day_of_week'] == ['monday', 'wednesday']
|
|
||||||
|
|
||||||
def test_to_dict_with_off_dates(self):
|
|
||||||
"""Тест конвертации с днями отдыха"""
|
|
||||||
prefs = OperatorPreferences("Тестовый")
|
|
||||||
prefs.off_on_dates = [5, 12, 19]
|
|
||||||
result = prefs.to_dict()
|
|
||||||
assert result['off_dates'] == [5, 12, 19]
|
|
||||||
|
|
||||||
def test_to_dict_all(self):
|
|
||||||
"""Тест конвертации всех полей"""
|
|
||||||
prefs = OperatorPreferences("Тестовый")
|
|
||||||
prefs.work_on_dates = [10, 15]
|
|
||||||
prefs.work_on_day_of_week = [DayOfWeek.MONDAY]
|
|
||||||
prefs.work_only_shift = 1
|
|
||||||
prefs.off_on_dates = [5]
|
|
||||||
result = prefs.to_dict()
|
|
||||||
assert result['dates'] == [10, 15]
|
|
||||||
assert result['day_of_week'] == ['monday']
|
|
||||||
assert result['shift'] == 1
|
|
||||||
assert result['off_dates'] == [5]
|
|
||||||
|
|
||||||
def test_from_dict(self):
|
|
||||||
"""Тест создания из словаря"""
|
|
||||||
data = {
|
|
||||||
'dates': [10, 15],
|
|
||||||
'day_of_week': ['monday'],
|
|
||||||
'shift': 1,
|
|
||||||
'off_dates': [5]
|
|
||||||
}
|
|
||||||
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
|
||||||
assert prefs.work_on_dates == [10, 15]
|
|
||||||
assert prefs.work_on_day_of_week == [DayOfWeek.MONDAY]
|
|
||||||
assert prefs.work_only_shift == 1
|
|
||||||
assert prefs.off_on_dates == [5]
|
|
||||||
|
|
||||||
def test_from_dict_partial(self):
|
|
||||||
"""Тест создания из словаря с неполными данными"""
|
|
||||||
data = {'dates': [10]}
|
|
||||||
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
|
||||||
assert prefs.work_on_dates == [10]
|
|
||||||
assert prefs.work_on_day_of_week == []
|
|
||||||
assert prefs.work_only_shift is None
|
|
||||||
assert prefs.off_on_dates == []
|
|
||||||
|
|
||||||
def test_from_dict_invalid_day_of_week(self):
|
|
||||||
"""Тест обработки невалидного дня недели"""
|
|
||||||
data = {'day_of_week': ['invalid']}
|
|
||||||
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
|
||||||
assert prefs.work_on_day_of_week == []
|
|
||||||
|
|
||||||
def test_from_dict_invalid_shift(self):
|
|
||||||
"""Тест обработки невалидной смены"""
|
|
||||||
data = {'shift': 5}
|
|
||||||
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
|
||||||
assert prefs.work_only_shift is None
|
|
||||||
|
|
||||||
def test_from_dict_invalid_dates(self):
|
|
||||||
"""Тест обработки невалидных дат"""
|
|
||||||
data = {'dates': [32]}
|
|
||||||
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
|
||||||
assert prefs.work_on_dates == []
|
|
||||||
|
|
||||||
def test_from_dict_invalid_off_dates(self):
|
|
||||||
"""Тест обработки невалидных дней отдыха"""
|
|
||||||
data = {'off_dates': [32]}
|
|
||||||
prefs = OperatorPreferences.from_dict("Тестовый", data)
|
|
||||||
assert prefs.off_on_dates == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestPreferencesBuilder:
|
def test_shift():
|
||||||
"""Тесты для класса PreferencesBuilder"""
|
"""Тест модели смены"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Model Shift")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
def test_creation(self):
|
# Создание смены
|
||||||
"""Тест создания объекта"""
|
shift_date = date(2024, 12, 9) # Пятница
|
||||||
builder = PreferencesBuilder("Тестовый")
|
shift = Shift(
|
||||||
assert builder.operator_name == "Тестовый"
|
date=shift_date, # Используем datetime.date объект
|
||||||
assert builder._preferences is None
|
shift_type=ShiftTypeEnum.AFTERNOON,
|
||||||
|
start_time="14:00",
|
||||||
|
end_time="22:00",
|
||||||
|
department_required="Склад",
|
||||||
|
required_skills=["Evening"],
|
||||||
|
min_operators=2,
|
||||||
|
max_operators=4,
|
||||||
|
description="Вечерняя смена на складе"
|
||||||
|
)
|
||||||
|
|
||||||
def test_add_dates(self):
|
print(f"\nShift: {shift}")
|
||||||
"""Тест добавления дат"""
|
print(f"Date: {shift.date}")
|
||||||
builder = PreferencesBuilder("Тестовый")
|
print(f"Type: {shift.shift_type}")
|
||||||
builder.add_dates([10, 15])
|
print(f"Start time: {shift.start_time}")
|
||||||
assert builder._preferences.work_on_dates == [10, 15]
|
print(f"End time: {shift.end_time}")
|
||||||
|
print(f"Duration (hours): {shift.duration_hours}")
|
||||||
|
# Проверка совместимости
|
||||||
|
op = Operator(
|
||||||
|
id="OP001",
|
||||||
|
name="Иванов",
|
||||||
|
skills=["Afternoon"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_add_day_of_week(self):
|
is_available = shift.is_available(op)
|
||||||
"""Тест добавления дней недели"""
|
print(f"\nOperator {op.name} available for this shift: {is_available}")
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_day_of_week([DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY])
|
|
||||||
assert len(builder._preferences.work_on_day_of_week) == 2
|
|
||||||
|
|
||||||
def test_add_shift(self):
|
|
||||||
"""Тест добавления смены"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_shift(2)
|
|
||||||
assert builder._preferences.work_only_shift == 2
|
|
||||||
|
|
||||||
def test_add_off_dates(self):
|
|
||||||
"""Тест добавления дней отдыха"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_off_dates([5, 12])
|
|
||||||
assert builder._preferences.off_on_dates == [5, 12]
|
|
||||||
|
|
||||||
def test_to_dict(self):
|
|
||||||
"""Тест конвертации в словарь"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_dates([10])
|
|
||||||
builder.add_shift(1)
|
|
||||||
result = builder.to_dict()
|
|
||||||
assert result['dates'] == [10]
|
|
||||||
assert result['shift'] == 1
|
|
||||||
|
|
||||||
def test_to_dict_empty(self):
|
|
||||||
"""Тест конвертации пустых предпочтений"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
result = builder.to_dict()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_to_dict_invalid(self):
|
|
||||||
"""Тест обработки невалидных данных"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_dates([32]) # Невалидная дата
|
|
||||||
result = builder.to_dict()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_to_dict_invalid_day_of_week(self):
|
|
||||||
"""Тест обработки невалидного дня недели"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_day_of_week([DayOfWeek.MONDAY, "invalid"])
|
|
||||||
result = builder.to_dict()
|
|
||||||
assert result['day_of_week'] == ['monday']
|
|
||||||
|
|
||||||
def test_to_dict_invalid_shift(self):
|
|
||||||
"""Тест обработки невалидной смены"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_shift(5) # Невалидная смена
|
|
||||||
result = builder.to_dict()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_to_dict_invalid_off_dates(self):
|
|
||||||
"""Тест обработки невалидных дней отдыха"""
|
|
||||||
builder = PreferencesBuilder("Тестовый")
|
|
||||||
builder.add_off_dates([32]) # Невалидная дата
|
|
||||||
result = builder.to_dict()
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetDays:
|
def test_preferences():
|
||||||
"""Тесты для функции get_days"""
|
"""Тест модели предпочтений"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("TEST: Model Preferences")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
def test_days_count(self):
|
# Создание предпочтений
|
||||||
"""Проверка количества дней"""
|
prefs = OperatorPreferences(
|
||||||
days = get_days()
|
operator_id="OP001",
|
||||||
assert len(days) == 30 # Апрель 2026
|
shift_preferences=[],
|
||||||
|
day_preferences=[],
|
||||||
|
time_preferences=[],
|
||||||
|
rest_preferences=[],
|
||||||
|
sequence_preferences=[]
|
||||||
|
)
|
||||||
|
|
||||||
def test_days_format(self):
|
# Добавление предпочтений
|
||||||
"""Проверка формата дней"""
|
prefs.add_shift_preference(
|
||||||
days = get_days()
|
ShiftType.MORNING,
|
||||||
for day in days:
|
strength=80,
|
||||||
assert isinstance(day, str)
|
reason="Хочу работать утром"
|
||||||
assert len(day) == 10 # YYYY-MM-DD
|
)
|
||||||
assert '-' in day
|
|
||||||
|
|
||||||
def test_days_sorted(self):
|
prefs.add_day_preference(
|
||||||
"""Проверка сортировки дней"""
|
{1, 2, 3}, # Пн-Ср
|
||||||
days = get_days()
|
strength=70,
|
||||||
assert days == sorted(days)
|
reason="Лучше работать в начале недели"
|
||||||
|
)
|
||||||
|
|
||||||
def test_days_start(self):
|
prefs.add_time_preference(
|
||||||
"""Проверка первого дня"""
|
ShiftType.MORNING,
|
||||||
days = get_days()
|
strength=75,
|
||||||
assert days[0] == "2026-04-01"
|
reason="Вечерная работа - не хочу"
|
||||||
|
)
|
||||||
|
|
||||||
def test_days_end(self):
|
print(f"\nNumber of shift preferences: {len(prefs.shift_preferences)}")
|
||||||
"""Проверка последнего дня"""
|
print(f"Number of day preferences: {len(prefs.day_preferences)}")
|
||||||
days = get_days()
|
# Получение лучших предпочтений
|
||||||
assert days[-1] == "2026-04-30"
|
best_shift = prefs.get_best_shift_type()
|
||||||
|
best_days = prefs.get_best_days()
|
||||||
|
|
||||||
|
print(f"Best shift type: {best_shift}")
|
||||||
|
print(f"Best days: {best_days}")
|
||||||
|
|
||||||
|
def test_all():
|
||||||
|
"""Запуск всех тестов"""
|
||||||
|
test_operator()
|
||||||
|
test_shift()
|
||||||
|
test_preferences()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("All tests passed! ✓")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
class TestCreateModel:
|
if __name__ == "__main__":
|
||||||
"""Тесты для функции create_model"""
|
test_all()
|
||||||
|
|
||||||
def test_model_creation(self):
|
|
||||||
"""Тест создания модели"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
assert model is not None
|
|
||||||
assert variables is not None
|
|
||||||
assert operators_set is not None
|
|
||||||
assert len(operators_set) == len(OPERATORS)
|
|
||||||
|
|
||||||
def test_variables_count(self):
|
|
||||||
"""Тест количества переменных"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
expected_count = len(get_days()) * len(SHIFTS) * len(OPERATORS)
|
|
||||||
assert len(variables) == expected_count
|
|
||||||
|
|
||||||
def test_operators_set(self):
|
|
||||||
"""Тест множества операторов"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
assert operators_set == set(OPERATORS)
|
|
||||||
|
|
||||||
def test_model_has_constraints(self):
|
|
||||||
"""Тест наличия ограничений"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
# Модель должна иметь ограничения
|
|
||||||
assert model.NumConstraints() > 0
|
|
||||||
|
|
||||||
def test_model_has_hints(self):
|
|
||||||
"""Тест наличия подсказок"""
|
|
||||||
model, variables, operators_set = create_model()
|
|
||||||
# Модель должна иметь подсказки
|
|
||||||
assert model.NumHints() > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestSolveModel:
|
|
||||||
"""Тесты для функции solve_model"""
|
|
||||||
|
|
||||||
def test_solve_returns_bool(self):
|
|
||||||
"""Тест возврата булевого значения"""
|
|
||||||
result = solve_model()
|
|
||||||
assert isinstance(result, bool)
|
|
||||||
|
|
||||||
def test_solve_with_valid_config(self):
|
|
||||||
"""Тест решения с валидной конфигурацией"""
|
|
||||||
# Это может занять время, поэтому тестуем только факт вызова
|
|
||||||
try:
|
|
||||||
result = solve_model()
|
|
||||||
# Результат может быть True или False в зависимости от конфигурации
|
|
||||||
assert isinstance(result, bool)
|
|
||||||
except Exception as e:
|
|
||||||
pytest.fail(f"Ошибка при решении модели: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetSolution:
|
|
||||||
"""Тесты для функции get_solution"""
|
|
||||||
|
|
||||||
def test_solution_structure(self):
|
|
||||||
"""Тест структуры решения"""
|
|
||||||
schedule = get_solution()
|
|
||||||
assert isinstance(schedule, dict)
|
|
||||||
assert len(schedule) > 0
|
|
||||||
|
|
||||||
def test_solution_days(self):
|
|
||||||
"""Тест дней в решении"""
|
|
||||||
schedule = get_solution()
|
|
||||||
days = get_days()
|
|
||||||
assert set(schedule.keys()) == set(days)
|
|
||||||
|
|
||||||
def test_solution_shifts(self):
|
|
||||||
"""Тест смен в решении"""
|
|
||||||
schedule = get_solution()
|
|
||||||
for day, day_shifts in schedule.items():
|
|
||||||
assert isinstance(day_shifts, dict)
|
|
||||||
assert len(day_shifts) == len(SHIFTS)
|
|
||||||
|
|
||||||
def test_solution_operators(self):
|
|
||||||
"""Тест операторов в решении"""
|
|
||||||
schedule = get_solution()
|
|
||||||
for day, day_shifts in schedule.items():
|
|
||||||
for shift_idx, shift_data in day_shifts.items():
|
|
||||||
assert isinstance(shift_data, dict)
|
|
||||||
assert len(shift_data) == len(OPERATORS)
|
|
||||||
|
|
||||||
def test_solution_values(self):
|
|
||||||
"""Тест значений в решении"""
|
|
||||||
schedule = get_solution()
|
|
||||||
for day, day_shifts in schedule.items():
|
|
||||||
for shift_idx, shift_data in day_shifts.items():
|
|
||||||
for op_name, value in shift_data.items():
|
|
||||||
assert isinstance(value, bool)
|
|
||||||
assert value in [True, False]
|
|
||||||
|
|
||||||
def test_solution_consistency(self):
|
|
||||||
"""Тест согласованности решения"""
|
|
||||||
schedule = get_solution()
|
|
||||||
# Каждый день и смена должны иметь ровно одного оператора
|
|
||||||
for day, day_shifts in schedule.items():
|
|
||||||
for shift_idx, shift_data in day_shifts.items():
|
|
||||||
active_operators = [op for op, value in shift_data.items() if value]
|
|
||||||
assert len(active_operators) == 1
|
|
||||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests package."""
|
||||||
1
ui/__init__.py
Normal file
1
ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""UI package."""
|
||||||
1
ui/routes/__init__.py
Normal file
1
ui/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Routes package."""
|
||||||
1
ui/routes/config.py
Normal file
1
ui/routes/config.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""UI configuration."""
|
||||||
1
ui/routes/index.py
Normal file
1
ui/routes/index.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Main UI routes."""
|
||||||
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utilities package."""
|
||||||
Reference in New Issue
Block a user