Compare commits
1 Commits
master
...
very_hard_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f916d764c0 |
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
|
||||
108
README.md
108
README.md
@@ -7,10 +7,108 @@
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
├── main.py # Основная логика с генерацией графика
|
||||
├── shift_schedule.csv # Результат (заполненный график)
|
||||
├── README.md # Документация
|
||||
└── .continue/ # Система контроля версий
|
||||
OperatorSchedule/
|
||||
├── src/ # Основной исходный код
|
||||
│ ├── __init__.py # Корневой пакет
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -248,4 +346,4 @@
|
||||
Перед отправкой мне решения - проверь что все условия отрабатывают.
|
||||
Если у тебя есть какой-то вопрос или что-то непонятно - задай, я отвечу
|
||||
Ответ выдай в csv, в котором столбцы - день;1 смена;2 смена;3 смена;4 смена
|
||||
Если возможности подобрать такой график работ нет - то так и ответь
|
||||
Если возможности подобрать такой график работ нет - то так и ответь
|
||||
|
||||
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."""
|
||||
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
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests package."""
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integration tests package."""
|
||||
137
tests/test_models.py
Normal file
137
tests/test_models.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Тест моделей данных для операторов и смен"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
from datetime import date, datetime
|
||||
from domains.models.operator import Operator, ShiftType
|
||||
from domains.models.shift import Shift, ShiftTypeEnum
|
||||
from domains.models.preference import OperatorPreferences, Preference
|
||||
|
||||
|
||||
def test_operator():
|
||||
"""Тест модели оператора"""
|
||||
print("=" * 60)
|
||||
print("TEST: Model Operator")
|
||||
print("=" * 60)
|
||||
|
||||
# Создание оператора
|
||||
operator = Operator(
|
||||
id="OP001",
|
||||
name="Иванов Иван Иванович",
|
||||
department="Склад",
|
||||
email="ivanov@company.com",
|
||||
skills=["Morning", "Afternoon", "Evening"],
|
||||
preferred_days=[1, 2, 3, 4, 5],
|
||||
max_shifts_per_week=4,
|
||||
max_weekly_hours=32
|
||||
)
|
||||
|
||||
print(f"\nOperator: {operator}")
|
||||
print(f"ID: {operator.id}")
|
||||
print(f"Name: {operator.name}")
|
||||
print(f"Department: {operator.department}")
|
||||
print(f"Skills: {operator.skills}")
|
||||
print(f"Preferred days: {operator.preferred_days}")
|
||||
print(f"Can work 'Night' shift: {operator.can_work_shift(ShiftType.NIGHT)}")
|
||||
|
||||
# Проверка доступности
|
||||
availability = operator.get_availability()
|
||||
print(f"\nAvailability info:")
|
||||
print(f" Total shifts assigned: {availability['total_shifts_assigned']}")
|
||||
print(f" Available for assignment: {availability['available_for_assignment']}")
|
||||
|
||||
|
||||
def test_shift():
|
||||
"""Тест модели смены"""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Model Shift")
|
||||
print("=" * 60)
|
||||
|
||||
# Создание смены
|
||||
shift_date = date(2024, 12, 9) # Пятница
|
||||
shift = Shift(
|
||||
date=shift_date, # Используем datetime.date объект
|
||||
shift_type=ShiftTypeEnum.AFTERNOON,
|
||||
start_time="14:00",
|
||||
end_time="22:00",
|
||||
department_required="Склад",
|
||||
required_skills=["Evening"],
|
||||
min_operators=2,
|
||||
max_operators=4,
|
||||
description="Вечерняя смена на складе"
|
||||
)
|
||||
|
||||
print(f"\nShift: {shift}")
|
||||
print(f"Date: {shift.date}")
|
||||
print(f"Type: {shift.shift_type}")
|
||||
print(f"Start time: {shift.start_time}")
|
||||
print(f"End time: {shift.end_time}")
|
||||
print(f"Duration (hours): {shift.duration_hours}")
|
||||
# Проверка совместимости
|
||||
op = Operator(
|
||||
id="OP001",
|
||||
name="Иванов",
|
||||
skills=["Afternoon"]
|
||||
)
|
||||
|
||||
is_available = shift.is_available(op)
|
||||
print(f"\nOperator {op.name} available for this shift: {is_available}")
|
||||
|
||||
|
||||
def test_preferences():
|
||||
"""Тест модели предпочтений"""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST: Model Preferences")
|
||||
print("=" * 60)
|
||||
|
||||
# Создание предпочтений
|
||||
prefs = OperatorPreferences(
|
||||
operator_id="OP001",
|
||||
shift_preferences=[],
|
||||
day_preferences=[],
|
||||
time_preferences=[],
|
||||
rest_preferences=[],
|
||||
sequence_preferences=[]
|
||||
)
|
||||
|
||||
# Добавление предпочтений
|
||||
prefs.add_shift_preference(
|
||||
ShiftType.MORNING,
|
||||
strength=80,
|
||||
reason="Хочу работать утром"
|
||||
)
|
||||
|
||||
prefs.add_day_preference(
|
||||
{1, 2, 3}, # Пн-Ср
|
||||
strength=70,
|
||||
reason="Лучше работать в начале недели"
|
||||
)
|
||||
|
||||
prefs.add_time_preference(
|
||||
ShiftType.MORNING,
|
||||
strength=75,
|
||||
reason="Вечерная работа - не хочу"
|
||||
)
|
||||
|
||||
print(f"\nNumber of shift preferences: {len(prefs.shift_preferences)}")
|
||||
print(f"Number of day preferences: {len(prefs.day_preferences)}")
|
||||
# Получение лучших предпочтений
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_all()
|
||||
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