Выполнены этапы 2, 3, 4

Дополнен план работ этапом 7 (покрытие тестами)
This commit is contained in:
ki.sagidullin
2026-04-07 00:59:57 +05:00
parent a8f47d29f6
commit 5d2b665fa6
4 changed files with 293 additions and 82 deletions

274
models.py
View File

@@ -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:3020: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
return schedule