Выполнены этапы 2, 3, 4
Дополнен план работ этапом 7 (покрытие тестами)
This commit is contained in:
50
PLAN.md
50
PLAN.md
@@ -62,7 +62,30 @@
|
||||
|
||||
---
|
||||
|
||||
## Этап 4: Создание документации
|
||||
## Этап 4: Создание класса предпочтений операторов
|
||||
|
||||
**Файл:** `models.py` (или отдельный файл `preferences.py`)
|
||||
|
||||
**Что сделать:**
|
||||
- Создать класс `OperatorPreferences` для конструирования и управления предпочтениями операторов.
|
||||
- Реализовать цепочку методов (builder pattern) for удобного создания предпочтений.
|
||||
- Поддержать следующие типы предпочтений:
|
||||
- Желаемый рабочий день (конкретные даты месяца)
|
||||
- Желаемая смена (индекс смены 0-3)
|
||||
- Желаемый день недели для работы (понедельник, вторник, ...)
|
||||
- Желаемый выходной (конкретные даты месяца)
|
||||
- Реализовать метод `__str__` для отладки.
|
||||
- Обеспечить экспорт предпочтений в формат, пригодный для `config.py`.
|
||||
|
||||
**Требования к реализации:**
|
||||
- Класс должен позволять конструировать предпочтения через цепочку методов.
|
||||
- Поддерживать три типа предпочтений (как указано выше).
|
||||
- Экспортировать предпочтения в формат, пригодный для `config.py`.
|
||||
- Поддерживать метод `__str__` для отладки.
|
||||
|
||||
---
|
||||
|
||||
## Этап 5: Создание документации
|
||||
|
||||
**Файл:** `USER_GUIDE.md`
|
||||
|
||||
@@ -74,13 +97,33 @@
|
||||
|
||||
---
|
||||
|
||||
## Этап 5: Тестирование
|
||||
## Этап 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` - интеграционные тесты
|
||||
|
||||
---
|
||||
|
||||
@@ -88,3 +131,6 @@
|
||||
- Конфигурация в одном файле `config.py`
|
||||
- Простое редактирование без знания Python
|
||||
- Четкая документация в `USER_GUIDE.md`
|
||||
- Гибкая система предпочтений через класс `OperatorPreferences`
|
||||
- Полноценный набор тестов для всех компонентов
|
||||
|
||||
|
||||
34
config.py
34
config.py
@@ -28,19 +28,33 @@ OPERATORS = [
|
||||
]
|
||||
|
||||
# 💖 ПРЕДПОЧТЕНИЯ ОПЕРАТОРОВ
|
||||
# format: {имя_оператора: список_дней_или_смена}
|
||||
# Формат: {имя_оператора: объект_предпочтений}
|
||||
# Для создания предпочтений используйте класс 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 = {
|
||||
"Иванов": [10, 13], # 10 и 13 апреля
|
||||
"Алексеев": "понедельник", # только понедельники
|
||||
"Сидоров": [25, 26], # 25 и 26 апреля
|
||||
"Козобородов": [2], # только смена 2 (11:30–20:00)
|
||||
"Трудяшкина": [4], # 4 апреля
|
||||
"Петрова": [8, 29], # 8 и 29 апреля
|
||||
"Иванов": {"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 апреля
|
||||
}
|
||||
|
||||
# 📝 ПОЯСНЕНИЯ ДЛЯ РЕДАКТИРОВАНИЯ:
|
||||
# - Дни месяца указываются по числам: [10, 13] = 10 и 13 апреля
|
||||
# - "понедельник" означает работать только в понедельник
|
||||
# - Числа 0-3 в списках указывают на номер смены (0 = первая смена)
|
||||
# - Дни месяца указываются в списке: {"dates": [10, 13]} = 10 и 13 апреля
|
||||
# - "понедельник" (или "monday") означает работать только в понедельник
|
||||
# - Число в "shift": 0-3 указывает на номер смены (0 = первая смена)
|
||||
# - Для добавления/удаления дней меняйте списки в квадратных скобках
|
||||
# - Для изменения имени оператора меняйте строку в OPERATORS
|
||||
# - Для установки дня, когда оператор НЕ работает, используйте: {"off_dates": [5]}
|
||||
|
||||
15
main.py
15
main.py
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
main.py - Основ
|
||||
|
||||
ный скрипт для создания графика смен
|
||||
main.py - Основной скрипт для создания графика смен
|
||||
Использует модуль models.py для создания и решения модели
|
||||
"""
|
||||
from models import create_model, solve_model, get_solution, SHIFTS, OPERATORS
|
||||
from models import create_model, solve_model, get_solution
|
||||
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
||||
import csv
|
||||
|
||||
def generate_csv_report(schedule: dict, filename: str = 'shift_schedule.csv'):
|
||||
@@ -46,25 +45,25 @@ def main():
|
||||
print("=" * 60)
|
||||
|
||||
# Шаг 1: Создаем модель
|
||||
print("\n[1] Создание модели...")
|
||||
print("\n[1] Создание модели. ..")
|
||||
model, variables, operators_set = create_model()
|
||||
print(f" Модель создана с {len(variables)} переменными")
|
||||
print(f" Операторы: {', '.join(operators_set)}")
|
||||
|
||||
# Шаг 2: Решаем модель
|
||||
print("\n[2] Решение модели...")
|
||||
print("\n[2] Решение модели. ..")
|
||||
success = solve_model()
|
||||
|
||||
if success:
|
||||
print(" ✅ Модель решена успешно!")
|
||||
|
||||
# Шаг 3: Получаем решение
|
||||
print("\n[3] Получение решения...")
|
||||
print("\n[3] Получение решения. ..")
|
||||
schedule = get_solution()
|
||||
print(f" Решение получено для {len(schedule)} дней")
|
||||
|
||||
# Шаг 4: Генерируем CSV-отчет
|
||||
print("\n[4] Генерация CSV-отчета...")
|
||||
print("\n[4] Генерация CSV-отчета. ..")
|
||||
generate_csv_report(schedule, 'shift_schedule.csv')
|
||||
|
||||
else:
|
||||
|
||||
270
models.py
270
models.py
@@ -1,44 +1,149 @@
|
||||
"""
|
||||
Модуль для создания и решения модели CP-SAT
|
||||
"""
|
||||
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 typing import List, Dict, Tuple
|
||||
from config import YEAR, MONTH, SHIFTS, OPERATORS
|
||||
|
||||
# Дни работы
|
||||
def get_days(year: int = 2026, month: int = 4) -> List[str]:
|
||||
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)]
|
||||
return [f"{YEAR}-{MONTH:02d}-{i:02d}" for i in range(1, 31)]
|
||||
|
||||
# Смены
|
||||
SHIFTS = [
|
||||
(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
|
||||
]
|
||||
|
||||
# Операторы
|
||||
OPERATORS = [
|
||||
"Иванов",
|
||||
"Алексеев",
|
||||
"Сидоров",
|
||||
"Козобородов",
|
||||
"Трудяшкина",
|
||||
"Петрова"
|
||||
]
|
||||
|
||||
# Предпочтения операторов
|
||||
OPERATOR_PREFERENCES = {
|
||||
"Иванов": ["2026-04-10", "2026-04-13"],
|
||||
"Алексеев": [(i, 0) for i in get_days() if (int(i[-2:]) - 1) % 7 == 0], # Понедельники
|
||||
"Сидоров": ["2026-04-25", "2026-04-26"],
|
||||
"Козобородов": [(i, 2) for i in get_days()], # Предпочитает смену 11:30–20:00
|
||||
"Трудяшкина": ["2026-04-04"],
|
||||
"Петрова": ["2026-04-08", "2026-04-29"]
|
||||
}
|
||||
|
||||
|
||||
def create_model() -> Tuple[cp_model.CpModel, Dict, Dict]:
|
||||
def create_model() -> Tuple[cp_model.CpModel, Dict, Set]:
|
||||
"""
|
||||
Создаёт и настраивает CP-модель.
|
||||
|
||||
@@ -58,28 +163,75 @@ def create_model() -> Tuple[cp_model.CpModel, Dict, Dict]:
|
||||
var_name = f"{op}_{day}_shift_{shift_idx}"
|
||||
variables[(day, shift_idx, op)] = model.NewBoolVar(var_name)
|
||||
|
||||
# Добавляем ограничения
|
||||
# Определяем множество операторов
|
||||
operators_set = set(OPERATORS)
|
||||
|
||||
# 1. Учёт предпочтений операторов
|
||||
for op, prefs in OPERATOR_PREFERENCES.items():
|
||||
if isinstance(prefs, list):
|
||||
for pref in prefs:
|
||||
if isinstance(pref, tuple):
|
||||
day, shift = pref
|
||||
day_key = f"{day}"
|
||||
shift_key = shift
|
||||
# Создаем ключ для переменных, используя стандартный формат дней
|
||||
# Нужно найти соответствующий день в списке days
|
||||
for d in days:
|
||||
if d.endswith(day.split('-')[2]) and d.startswith(f"{day.split('-')[0]}-{day.split('-')[1]}"):
|
||||
if f"{d}-{shift_key}" in pref:
|
||||
continue
|
||||
day, shift_idx_pref = pref
|
||||
model.AddHint(variables[(day, shift_idx_pref, op)], 1)
|
||||
break
|
||||
# Ожидаем, что 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:
|
||||
day = pref
|
||||
model.Add(sum(variables[(day, shift_idx, op)] for shift_idx in range(len(SHIFTS))) == 0)
|
||||
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:
|
||||
@@ -102,9 +254,9 @@ def create_model() -> Tuple[cp_model.CpModel, Dict, Dict]:
|
||||
for op in OPERATORS:
|
||||
model.Add(sum(variables[(day, shift_idx, op)] for shift_idx in range(len(SHIFTS))) <= 1)
|
||||
|
||||
return model, variables, set(OPERATORS)
|
||||
return model, variables, operators_set
|
||||
|
||||
def solve_model():
|
||||
def solve_model() -> bool:
|
||||
"""
|
||||
Решает модель.
|
||||
|
||||
@@ -119,7 +271,7 @@ def solve_model():
|
||||
|
||||
return status in (cp_model.OPTIMAL, cp_model.FEASIBLE)
|
||||
|
||||
def get_solution():
|
||||
def get_solution() -> Dict:
|
||||
"""
|
||||
Получает решение модели.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user