Skip to content

Commit d9a845e

Browse files
feat: create and generate pending recurrences
1 parent 063d5a0 commit d9a845e

File tree

10 files changed

+1187
-4
lines changed

10 files changed

+1187
-4
lines changed

app/models/medical_shift_recurrence.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ class MedicalShiftRecurrence < ApplicationRecord
2323

2424
validate :day_of_month_blank_for_weekly
2525
validate :day_of_week_blank_for_monthly
26+
validate :end_date_after_start_date
27+
validate :start_date_not_in_past
28+
29+
scope :active, -> { where(deleted_at: nil) }
30+
scope :needs_generation, MedicalShiftRecurrences::NeedsGenerationQuery
2631

2732
private
2833

@@ -37,4 +42,16 @@ def day_of_week_blank_for_monthly
3742

3843
errors.add(:day_of_week, "It must be empty for monthly_fixed_day recurrence.")
3944
end
45+
46+
def end_date_after_start_date
47+
return unless end_date.present? && start_date.present? && end_date < start_date
48+
49+
errors.add(:end_date, "End date must be after start date.")
50+
end
51+
52+
def start_date_not_in_past
53+
return unless start_date.present? && start_date < Time.zone.today
54+
55+
errors.add(:start_date, "Start date cannot be in the past.")
56+
end
4057
end
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# frozen_string_literal: true
2+
3+
module MedicalShiftRecurrences
4+
class Create < Actor
5+
input :attributes, type: Hash
6+
input :user_id, type: Integer
7+
8+
output :medical_shift_recurrence, type: MedicalShiftRecurrence
9+
output :shifts_created, type: Array, default: -> { [] }
10+
11+
GENERATION_HORIZON_MONTHS = 2
12+
13+
def call
14+
create_recurrence
15+
initialize_shifts_array
16+
generate_shifts
17+
end
18+
19+
private
20+
21+
def create_recurrence
22+
self.medical_shift_recurrence = MedicalShiftRecurrence.new(
23+
attributes.reverse_merge(user_id: user_id)
24+
)
25+
26+
fail!(error: medical_shift_recurrence.errors.full_messages) unless medical_shift_recurrence.save
27+
end
28+
29+
def initialize_shifts_array
30+
self.shifts_created = []
31+
end
32+
33+
def generate_shifts
34+
target_date = GENERATION_HORIZON_MONTHS.months.from_now.to_date
35+
dates = MedicalShiftRecurrences::RecurrenceDateCalculatorService.new(
36+
medical_shift_recurrence
37+
).dates_until(target_date)
38+
39+
dates.each do |date|
40+
result = MedicalShifts::Create.result(
41+
attributes: shift_attributes(date),
42+
user_id: user_id
43+
)
44+
45+
shifts_created << result.medical_shift if result.success?
46+
end
47+
48+
medical_shift_recurrence.update!(last_generated_until: target_date) if shifts_created.any?
49+
end
50+
51+
def shift_attributes(date)
52+
{
53+
start_date: date,
54+
start_hour: medical_shift_recurrence.start_hour,
55+
workload: medical_shift_recurrence.workload,
56+
hospital_name: medical_shift_recurrence.hospital_name,
57+
amount_cents: medical_shift_recurrence.amount_cents,
58+
medical_shift_recurrence_id: medical_shift_recurrence.id,
59+
paid: false
60+
}
61+
end
62+
end
63+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module MedicalShiftRecurrences
4+
class GeneratePending < Actor
5+
input :target_date, type: Date, default: -> { 2.months.from_now.to_date }
6+
7+
output :processed, type: Integer, default: 0
8+
output :shifts_created, type: Integer, default: 0
9+
output :errors, type: Array, default: -> { [] }
10+
11+
def call
12+
self.processed = 0
13+
self.shifts_created = 0
14+
self.errors = []
15+
16+
MedicalShiftRecurrence.needs_generation(target_date:).find_each do |recurrence|
17+
process_recurrence(recurrence)
18+
end
19+
end
20+
21+
private
22+
23+
def process_recurrence(recurrence)
24+
dates = MedicalShiftRecurrences::RecurrenceDateCalculatorService.new(recurrence).dates_until(target_date)
25+
26+
created_count = 0
27+
dates.each do |date|
28+
result = MedicalShifts::Create.call(
29+
attributes: shift_attributes(recurrence, date),
30+
user_id: recurrence.user_id
31+
)
32+
created_count += 1 if result.success?
33+
end
34+
35+
recurrence.update!(last_generated_until: target_date) if created_count.positive?
36+
37+
self.processed += 1
38+
self.shifts_created += created_count
39+
rescue StandardError => e
40+
errors << { recurrence_id: recurrence.id, error: e.message }
41+
end
42+
43+
def shift_attributes(recurrence, date)
44+
{
45+
start_date: date,
46+
start_hour: recurrence.start_hour,
47+
workload: recurrence.workload,
48+
hospital_name: recurrence.hospital_name,
49+
amount_cents: recurrence.amount_cents,
50+
medical_shift_recurrence_id: recurrence.id,
51+
paid: false
52+
}
53+
end
54+
end
55+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module MedicalShiftRecurrences
4+
class NeedsGenerationQuery < ApplicationQuery
5+
attr_reader :target_date, :relation
6+
7+
def initialize(target_date:, relation: MedicalShiftRecurrence)
8+
@target_date = target_date
9+
@relation = relation
10+
end
11+
12+
def call
13+
relation
14+
.active
15+
.where(
16+
"last_generated_until IS NULL OR last_generated_until < ?",
17+
target_date
18+
)
19+
end
20+
end
21+
end
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# frozen_string_literal: true
2+
3+
module MedicalShiftRecurrences
4+
class RecurrenceDateCalculatorService
5+
def initialize(recurrence)
6+
@recurrence = recurrence
7+
end
8+
9+
def dates_until(target_date)
10+
return [] if @recurrence.deleted_at.present?
11+
12+
dates = []
13+
current_date = starting_point
14+
end_date = effective_end_date(target_date)
15+
16+
while current_date && current_date <= end_date
17+
dates << current_date
18+
current_date = next_occurrence(current_date)
19+
20+
break if dates.size > 365 # Safety
21+
end
22+
23+
dates
24+
end
25+
26+
private
27+
28+
def starting_point
29+
if @recurrence.last_generated_until.present?
30+
next_occurrence(@recurrence.last_generated_until)
31+
else
32+
first_occurrence_after_start_date
33+
end
34+
end
35+
36+
def first_occurrence_after_start_date
37+
case @recurrence.frequency
38+
when "weekly"
39+
find_next_weekly_after(@recurrence.start_date, @recurrence.day_of_week)
40+
when "biweekly"
41+
find_next_biweekly_after(@recurrence.start_date, @recurrence.day_of_week)
42+
when "monthly_fixed_day"
43+
find_next_month_day_after(@recurrence.start_date, @recurrence.day_of_month)
44+
end
45+
end
46+
47+
def find_next_weekly_after(from_date, target_wday)
48+
# Se from_date já é o dia correto, pula 7 dias
49+
return from_date + 7.days if from_date.wday == target_wday
50+
51+
# Caso contrário, encontra o próximo dia da semana
52+
days_ahead = target_wday - from_date.wday
53+
days_ahead += 7 if days_ahead < 0
54+
55+
from_date + days_ahead.days
56+
end
57+
58+
def find_next_biweekly_after(from_date, target_wday)
59+
# Se from_date já é o dia correto, pula 14 dias (2 semanas)
60+
return from_date + 14.days if from_date.wday == target_wday
61+
62+
# Caso contrário, encontra o próximo dia da semana
63+
days_ahead = target_wday - from_date.wday
64+
days_ahead += 7 if days_ahead < 0
65+
66+
from_date + days_ahead.days
67+
end
68+
69+
def find_next_month_day_after(from_date, target_day)
70+
# Se é o dia correto no mês atual, pula para o próximo mês
71+
if from_date.day == target_day && month_has_day?(from_date, target_day)
72+
return next_month_with_day(from_date, target_day)
73+
end
74+
75+
# Tenta no mês atual primeiro
76+
return from_date.change(day: target_day) if from_date.day < target_day && month_has_day?(from_date, target_day)
77+
78+
# Se não, próximo mês
79+
next_month_with_day(from_date, target_day)
80+
end
81+
82+
def next_occurrence(from_date)
83+
case @recurrence.frequency
84+
when "weekly"
85+
from_date + 7.days
86+
when "biweekly"
87+
from_date + 14.days
88+
when "monthly_fixed_day"
89+
next_month_with_day(from_date, @recurrence.day_of_month)
90+
end
91+
end
92+
93+
def next_month_with_day(from_date, target_day)
94+
current = from_date.next_month.beginning_of_month
95+
96+
12.times do
97+
return current.change(day: target_day) if month_has_day?(current, target_day)
98+
99+
current = current.next_month
100+
end
101+
102+
nil
103+
end
104+
105+
def month_has_day?(date, day)
106+
day <= date.end_of_month.day
107+
end
108+
109+
def effective_end_date(target_date)
110+
dates = [target_date]
111+
dates << @recurrence.end_date if @recurrence.end_date.present?
112+
dates.min
113+
end
114+
end
115+
end

spec/factories/medical_shift_recurrences.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
frequency { "weekly" }
88
day_of_week { 3 } # quarta-feira
9-
start_date { Date.tomorrow }
10-
workload { "12h" }
11-
start_hour { "19:00" }
12-
hospital_name { "Hospital Teste" }
9+
start_date { Time.zone.today }
10+
workload { MedicalShifts::Workloads::SIX }
11+
start_hour { "19:00:00" }
12+
hospital_name { create(:hospital).name }
1313
amount_cents { 120_000 }
1414

1515
trait :weekly do

0 commit comments

Comments
 (0)