Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

10-availability management api #11

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fd07bc9
feat: #10 improve shift table ui
adrianrbp Aug 11, 2024
d84cbda
feat: #10 define engineers availability route
adrianrbp Aug 11, 2024
5a6ebc4
refactor: #10 change frontend availability structure from strings to …
adrianrbp Aug 12, 2024
bd12ddd
config: #10 update readme screenshots
adrianrbp Aug 12, 2024
6a5ea48
fix: #10 update e2e editAvailability test
adrianrbp Aug 12, 2024
1bc7ff8
fix: #10 update e2e check company service shifts test
adrianrbp Aug 12, 2024
3fc7308
feat: #10 add availability model
adrianrbp Aug 12, 2024
9042e25
feat: #10 validate tiem is between 0 and 23 hours
adrianrbp Aug 12, 2024
d16bef8
feat: #10 add availability endpoint
adrianrbp Aug 12, 2024
144f589
feat: #10 add start and end hour to shifts
adrianrbp Aug 12, 2024
44fbbc9
config: #10 annotate models
adrianrbp Aug 12, 2024
6c68625
refactor: #10 shift factory from string times to integer hours
adrianrbp Aug 12, 2024
87ea9b6
config: #10 add seeds with random availabilities
adrianrbp Aug 12, 2024
0b428fe
feat: #10 define Engineer shifts as assigned hours
adrianrbp Aug 12, 2024
c4241f1
feat: #10 udpate readme with new logic
adrianrbp Aug 14, 2024
1b997a1
feat: #10 update factories to support simpler seeds samples
adrianrbp Aug 14, 2024
504ebfe
feat: #10 update fetch shifts service to use new hour info
adrianrbp Aug 14, 2024
bf0c5e9
feat: #10 add relation between engineer and shift through engineer_sh…
adrianrbp Aug 14, 2024
b6a9aa0
feat: #10 add phase 1 algorithm: assign shifts where there is only on…
adrianrbp Aug 14, 2024
0b2f56d
feat: #10 add phase 2 algorithm: assign shifts where there are 2 or m…
adrianrbp Aug 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified 1-shift_management.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified 2-availability_management.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 47 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,35 +70,53 @@
1. CompanyService
id: 1
name: "Service A"
contract_start_date: "2024-08-01"
contract_end_date: "2024-08-31"
contract_start_week: "2024-30"
contract_start_date: "2024-07-22"
contract_end_week: "2024-34"
contract_end_date: "2024-08-25"

2. Engineer
id: 1
name:"Alice Smith"
color:"Bob Johnson"

3. Shift
3. CompanyServiceEngineer
company_service:1
engineer: 1

4. Shift
company_service:1
engineer:(sin asignar)
week:"2024-32"
day:"Monday"
start_time:"2024-08-07 09:00:00"
end_time:"2024-08-07 10:00:00"
start_hour: 15
end_hour: 20
start_time:"15:00"
end_time:"20:00"

4. Availability
5. Availability
id: 1
engineer:1
week:"2024-32"
day:"Monday"
start_time:"2024-08-07 09:00:00"
end_time:"2024-08-07 10:00:00"
available:true
time:17

6. EngineerShift
shift: 1
availability: 1
start_hour: 17
end_hour: 18


#### Modelos - Instancias Factory Bot
```ruby
# 1. CompanyService
FactoryBot.attributes_for :company_service
=> {:name=>"Farrell, Mohr and Haley", :contract_start_date=>Thu, 18 Jul 2024, :contract_end_date=>Tue, 20 Aug 2024}
=> {:name=>"Ward and Sons",
:contract_start_week=>"2024-30",
:contract_start_date=>Mon, 22 Jul 2024,
:contract_end_week=>"2024-34",
:contract_end_date=>Sun, 25 Aug 2024}

# 2. Engineer
FactoryBot.attributes_for :engineer
Expand All @@ -109,12 +127,15 @@ FactoryBot.attributes_for :engineer

# 4. Shift
FactoryBot.attributes_for :shift
=> {:week=>"2024-32", :day=>"Tuesday", :start_time=>"13:00", :end_time=>"18:00"}
# 5. EngineerShift
=> {:week=>"2024-33", :day=>"Friday", :start_hour=>19, :end_hour=>23, :start_time=>"19:00", :end_time=>"23:00"}

# 6. Availability
# 5. Availability
FactoryBot.attributes_for :availability
=> {:week=>"2024-33", :day=>"Friday", :time=>17}

# 6. EngineerShift (Assigned Engineers)
FactoryBot.attributes_for :engineer_shift
=> {:start_hour=>20, :end_hour=>22}
```

#### Arquitectura Frontend (Grafica Figma)
Expand All @@ -123,6 +144,19 @@ FactoryBot.attributes_for :availability
- provider: useShiftManagement
- CompanyServiceApi.ts
#### Arquitectura Backend (Grafica Figma)
Services:
1. GET /shifts - List all shifts (EngineerShifts-assigned) [Shift+EngineerShifts]
- FetchShiftsService
2. GET /availability - List all availabilities (Availability)
- EngineerAvailabilityService
3. POST /availability - Assign Engineers to Shifts
- EngineerAvailabilityService [Update Availability + Create EngineerShifts]
- ShiftAssignmentService

### Assignment Algorithm
1. Asignación inicial: horarios donde solo un ingeniero está disponible
2. Asignación optimizada: horarios con múltiples ingenieros disponibles
3. Ajuste final para balancear las horas a lo largo de la semana


### Ejecución
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ def index
week = params[:week]
@engineers = company_service.engineers
end

def availability
company_service = params[:company_service_id]
week = params[:week]
@availability = EngineerAvailabilityService.new(company_service, week).call
end
end
end
17 changes: 17 additions & 0 deletions backend/app/models/availability.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# == Schema Information
#
# Table name: availabilities
#
# id :bigint not null, primary key
# engineer_id :bigint not null
# week :string
# day :string
# time :integer
# created_at :datetime not null
# updated_at :datetime not null
#
class Availability < ApplicationRecord
belongs_to :engineer

validates :time, inclusion: { in: 0..23, message: "must be between 0 and 23" }
end
2 changes: 2 additions & 0 deletions backend/app/models/company_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# contract_end_date :datetime
# created_at :datetime not null
# updated_at :datetime not null
# contract_start_week :string
# contract_end_week :string
#
class CompanyService < ApplicationRecord
has_many :company_service_engineers, dependent: :destroy
Expand Down
3 changes: 3 additions & 0 deletions backend/app/models/engineer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@
class Engineer < ApplicationRecord
has_many :company_service_engineers, dependent: :destroy
has_many :company_services, through: :company_service_engineers
has_many :availabilities, dependent: :destroy
has_many :engineer_shifts
has_many :shifts, through: :engineer_shifts
end
11 changes: 11 additions & 0 deletions backend/app/models/engineer_shift.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class EngineerShift < ApplicationRecord
belongs_to :engineer
belongs_to :shift

validates :start_hour, :end_hour, presence: true
validates :start_hour, :end_hour, numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 23
}
end
11 changes: 11 additions & 0 deletions backend/app/models/shift.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@
# end_time :time
# created_at :datetime not null
# updated_at :datetime not null
# start_hour :integer
# end_hour :integer
#
class Shift < ApplicationRecord
belongs_to :company_service
has_many :engineer_shifts
has_many :engineers, through: :engineer_shifts

validates :start_hour, :end_hour, presence: true
validates :start_hour, :end_hour, numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 23
}
end
31 changes: 31 additions & 0 deletions backend/app/services/engineer_availability_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class EngineerAvailabilityService
def initialize(company_service_id, week)
@company_service = CompanyService.find(company_service_id)
@week = week
end

def call
engineers = @company_service.engineers

engineers.map do |engineer|
{
engineer: engineer.id,
availability: available_times(engineer)
}
end
end

private

def available_times(engineer)
availability_records = engineer.availabilities.where(week: @week)

availability_by_day = availability_records.group_by { |record| record.day }
availability_by_day.map do |day, records|
{
day: day,
availableTimes: records.map(&:time)
}
end
end
end
32 changes: 20 additions & 12 deletions backend/app/services/fetch_shifts_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ def call
def shifts_by_day
# "Monday" => [shifts]
@company_service.shifts
.where(week: @company_service.contract_start_week)
# .where(week: @company_service.contract_start_week)
.where(week: @week)
.group_by(&:day)
end

Expand All @@ -40,21 +41,28 @@ def formatted_day_label(day)
def format_time_blocks(shifts)
shifts.map do |shift|
{
start_time: shift.start_time.strftime("%H:%W"),
end_time: shift.end_time.strftime("%H:%W"),
amount_of_hours: ((shift.end_time - shift.start_time) / 1.hour).to_i,
engineer: nil #format_engineer(shift.engineer)
start_time: format_time(shift.start_hour),
end_time: format_time(shift.end_hour),
amount_of_hours: shift.end_hour - shift.start_hour,
engineer: nil # format_engineer(shift.engineer_shifts)
}
end
end
def format_time(hour)
hour_string = hour.to_s.rjust(2, '0') # Ensure the hour is two digits
"#{hour_string}:00"
end

def format_engineer(engineer)
return nil unless engineer.present?
def format_engineer(engineer_shifts)
return nil unless engineer_shifts.present?

{
id: engineer.id,
name: engineer.name,
color: engineer.color
}
engineer_shifts.map do |engineer_shift|
engineer = engineer_shift.engineer
{
id: engineer.id,
name: engineer.name,
color: engineer.color
}
end
end
end
127 changes: 127 additions & 0 deletions backend/app/services/shift_assignment_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
class ShiftAssignmentService
attr_reader :company_service, :week, :availabilities, :assignments

def initialize(company_service, week)
@company_service = company_service
@week = week
@availabilities = load_availabilities
@assignments = []
end

def assign_shifts
shifts = Shift.where(company_service: company_service, week: week)

# Fase 1: Asignación inicial - horarios donde solo un ingeniero está disponible
shifts.each do |shift|
assign_shifts_with_unique_availability(shift)
end

# Fase 2: Asignación optimizada - horarios con múltiples ingenieros disponibles
shifts.each do |shift|
assign_shifts_with_collision(shift, 2)
end

save_assignments(assignments)
end

private

def load_availabilities
Availability.where(week: week)
.includes(:engineer)
.group_by { |a| [a.day, a.time] }
end

def assign_shifts_with_unique_availability(shift)
(shift.start_hour...shift.end_hour).each do |hour|
available_engineers = availabilities[[shift.day, hour]].map(&:engineer)

if available_engineers.size == 1
engineer = available_engineers.first

last_assignment = assignments.last

if last_assignment && last_assignment[:engineer] == engineer && last_assignment[:shift] == shift && last_assignment[:end_hour] == hour
# Extend the end hour of the existing assignment
last_assignment[:end_hour] = hour + 1
else
assignments << {
engineer: engineer,
shift: shift,
start_hour: hour,
end_hour: hour + 1
}
end
end
end
end

def assign_shifts_with_collision(shift, collision_count)
minimum_consecutive_hours = 4
shift_hours = (shift.start_hour...shift.end_hour).to_a

shift_hours.each_with_index do |hour, index|
next if assignments.any? { |a| a[:shift] == shift && a[:start_hour] == hour }

available_engineers = availabilities[[shift.day, hour]].map(&:engineer)
# Filter cases where availability in an hour is 2 or 3 (collision_count)
if available_engineers.size == collision_count
sorted_engineers = available_engineers.sort_by { |e| total_hours_assigned(e) }

sorted_engineers.each do |engineer|
block_start = hour
block_end = block_start

# Intentar extender el bloque de horas hasta donde sea posible, con un mínimo de 4 horas
while block_end + 1 < shift.end_hour &&
availabilities[[shift.day, block_end + 1]].map(&:engineer).include?(engineer)
block_end += 1
end

# Verificar si el bloque tiene al menos 4 horas consecutivas
if block_end - block_start + 1 >= minimum_consecutive_hours
# Crear asignación para el bloque de horas consecutivas
assignments << {
engineer: engineer,
shift: shift,
start_hour: block_start,
end_hour: block_end + 1
}

# Saltar las horas ya asignadas en el bloque
shift_hours.slice!(index, block_end - block_start + 1)
break
end
end

# Assign 1 hour if no continuous hours
unless shift_hours.empty?
assignments << {
engineer: sorted_engineers.first,
shift: shift,
start_hour: hour,
end_hour: hour + 1
}
end
end
end
end

def save_assignments(assignments)
EngineerShift.transaction do
assignments.each do |assignment|
EngineerShift.create!(
engineer: assignment[:engineer],
shift: assignment[:shift],
start_hour: assignment[:start_hour],
end_hour: assignment[:end_hour]
)
end
end
end

def total_hours_assigned(engineer)
assignments.select { |a| a[:engineer] == engineer }
.sum { |a| a[:end_hour] - a[:start_hour] }
end
end
Loading
Loading