diff --git a/PLAN.md b/PLAN.md index 8218e43..ce85c3e 100644 --- a/PLAN.md +++ b/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` +- Полноценный набор тестов для всех компонентов + diff --git a/config.py b/config.py index a4a9c31..fad8ca3 100644 --- a/config.py +++ b/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 \ No newline at end of file +# - Для изменения имени оператора меняйте строку в OPERATORS +# - Для установки дня, когда оператор НЕ работает, используйте: {"off_dates": [5]} diff --git a/main.py b/main.py index e5a79d8..9a70ee0 100644 --- a/main.py +++ b/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: diff --git a/models.py b/models.py index 3cca7b3..db42384 100644 --- a/models.py +++ b/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,29 +163,76 @@ 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: for shift_idx in range(len(SHIFTS)): @@ -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: """ Получает решение модели. @@ -138,4 +290,4 @@ def get_solution(): for shift_idx in range(len(SHIFTS)) } - return schedule \ No newline at end of file + return schedule